Learn With Champak · Programmer's Picnic

GitHub Workflows:
Zero to Infinity

Learn GitHub Actions by building real automation for React websites and Flutter Android apps—starting with Hello World and finishing with a tested, automatically deployed two-player Tic-Tac-Toe game.

GitHub ActionsReact + ViteFlutter AndroidAPK & AABCI/CDTic-Tac-Toe
What will happen by the end? Whenever we push code, GitHub will automatically test it. React will be built and published to GitHub Pages. Flutter will be analyzed, tested, and converted into downloadable APK and AAB files. We will apply the same pipeline to two complete two-player Tic-Tac-Toe applications.

Level 0: What is a GitHub workflow?

A GitHub workflow is an automated process stored as a YAML file inside .github/workflows/. An event such as a push or pull request starts the workflow. GitHub then gives us a temporary computer called a runner. The runner follows our jobs and steps in order.

Push or PRWorkflowRunnerTest & BuildDeploy or Artifact
TermMeaningExample
WorkflowThe complete automation fileandroid.yml
EventWhat starts the workflowPush to main
JobA unit of work on one runnerTest or deploy
StepOne command or reusable actionnpm test
ArtifactA file saved from a runapp-release.apk
CI verifies code continuously. CD makes the verified result available through deployment, an artifact, a release, or an app store.

Level 1: Our first Hello World workflow

Create .github/workflows/hello.yml in any GitHub repository.

hello.yml
name: Hello workflow

on:
  push:
  workflow_dispatch:

jobs:
  hello:
    runs-on: ubuntu-latest
    steps:
      - name: Say hello
        run: echo "Hello from GitHub Actions"

      - name: Show repository
        run: echo "Repository is ${{ github.repository }}"

Commit and push the file. Open Repository → Actions, select the run, and open the hello job. The workflow_dispatch event also provides a manual Run workflow button.

Level 2: Reading workflow YAML

  • name is the label displayed on the Actions page.
  • on contains the events that start the workflow.
  • jobs contains independent pieces of work.
  • runs-on selects the runner operating system.
  • steps execute sequentially inside a job.
  • uses calls a reusable action; run executes a shell command.
  • ${{ ... }} is GitHub expression syntax.
YAML rule: indentation defines structure. Use spaces, never tabs.

Why do we need checkout?

Reusable action
- uses: actions/checkout@v4

A runner starts as a fresh machine. Checkout places our repository code on that machine. Without it, commands such as npm test or flutter build cannot see the project.

Level 3: Events, dependencies, permissions and secrets

Common triggers
on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]
  workflow_dispatch:
  schedule:
    - cron: '30 2 * * 1'

This supports pushes to main, version tags such as v1.0.0, pull requests targeting main, manual runs, and Monday 02:30 UTC schedules.

Make one job wait for another

Job dependency
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Testing"

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying after tests pass"

Use the smallest permissions

Least privilege
permissions:
  contents: read

Passwords, signing keys and access tokens belong in Settings → Secrets and variables → Actions. Never commit a secret or print it in a log.

Level 4: React Hello World with Vite

Terminal
npm create vite@latest react-hello -- --template react
cd react-hello
npm install
npm run dev

Replace the component with a minimal Hello World:

App.jsx
export default function App() {
  return <h1>Hello World from React</h1>;
}

React continuous integration

.github/workflows/react-ci.yml
name: React CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm test
      - run: npm run build
Why npm ci? It installs exactly what is recorded in package-lock.json, making automated builds repeatable. Commit the lockfile.

Level 5: Deploy React automatically to GitHub Pages

Open Settings → Pages → Build and deployment → Source and choose GitHub Actions. For a normal project repository, Vite needs the repository name as its base path.

.github/workflows/react-pages.yml
name: Test and deploy React

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test
      - run: npm run build -- --base=/${{ github.event.repository.name }}/
      - uses: actions/configure-pages@v5
        if: github.event_name != 'pull_request'
      - uses: actions/upload-pages-artifact@v3
        if: github.event_name != 'pull_request'
        with:
          path: dist

  deploy:
    if: github.event_name != 'pull_request'
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Deploy
        id: deployment
        uses: actions/deploy-pages@v4

Pull requests are tested and built, but not deployed. A successful push to main is usually published at:

https://YOUR-USER.github.io/YOUR-REPOSITORY/

Level 6: Flutter Android Hello World

Terminal
flutter create flutter_hello
cd flutter_hello
flutter run

A minimal Flutter interface:

lib/main.dart
import 'package:flutter/material.dart';

void main() => runApp(
  const MaterialApp(
    home: Scaffold(
      body: Center(child: Text('Hello World')),
    ),
  ),
);

Always prove the commands locally before automating them:

Local verification
flutter pub get
flutter analyze
flutter test
flutter build apk --release

Level 7: Build downloadable Android APK and AAB files

.github/workflows/android.yml
name: Test and build Android

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]
  workflow_dispatch:

jobs:
  android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '17'

      - uses: subosito/flutter-action@v2
        with:
          channel: stable
          cache: true

      - run: flutter pub get
      - run: flutter analyze
      - run: flutter test
      - run: flutter build apk --release

      - uses: actions/upload-artifact@v4
        with:
          name: flutter-app-apk
          path: build/app/outputs/flutter-apk/app-release.apk
          retention-days: 14

      - run: flutter build appbundle --release

      - uses: actions/upload-artifact@v4
        with:
          name: flutter-app-aab
          path: build/app/outputs/bundle/release/app-release.aab
          retention-days: 14

After the workflow succeeds, open Actions → Run summary → Artifacts. GitHub provides the APK and AAB as ZIP downloads.

OutputBest use
APKDirect installation and testing on Android devices
AABPreferred upload format for Google Play
Production signing: Play Store distribution needs a protected upload keystore and signing configuration. Store the encoded keystore and passwords as GitHub Secrets. Never commit signing material.

Level 8: Turn CI into a quality gate

Protect the main branch using Settings → Rules → Rulesets:

  • require a pull request before merging;
  • require the React or Flutter workflow to pass;
  • require the branch to be up to date;
  • block force pushes;
  • optionally require one review.
Feature branchPull requestAutomatic checksReviewMerge

Infinity Project: Two-player Tic-Tac-Toe

We now apply the full workflow to the same game in React and Flutter. Two people play on the same browser or Android device.

Game rules

  1. The board has nine cells.
  2. X starts and players alternate X and O.
  3. An occupied cell cannot be played again.
  4. Three equal marks in a row, column, or diagonal win.
  5. A full board without a winner is a draw.
  6. New Game clears the board and restores X's turn.

Shared winner algorithm

React — game.js
const winningLines = [
  [0,1,2], [3,4,5], [6,7,8],
  [0,3,6], [1,4,7], [2,5,8],
  [0,4,8], [2,4,6]
];

export function calculateWinner(board) {
  for (const [a, b, c] of winningLines) {
    if (board[a] &&
        board[a] === board[b] &&
        board[a] === board[c]) {
      return board[a];
    }
  }
  return null;
}

React game component

React — App component
function App() {
  const [board, setBoard] = useState(Array(9).fill(null));
  const [xTurn, setXTurn] = useState(true);
  const winner = calculateWinner(board);
  const draw = !winner && board.every(Boolean);

  function play(i) {
    if (board[i] || winner) return;
    const next = [...board];
    next[i] = xTurn ? 'X' : 'O';
    setBoard(next);
    setXTurn(!xTurn);
  }

  function reset() {
    setBoard(Array(9).fill(null));
    setXTurn(true);
  }

  const status = winner
    ? `Player ${winner} wins!`
    : draw
      ? 'Draw!'
      : `Player ${xTurn ? 'X' : 'O'} turn`;

  return (
    <main>
      <h1>Tic-Tac-Toe</h1>
      <p>{status}</p>
      <section className="board">
        {board.map((value, i) => (
          <button key={i} onClick={() => play(i)}>
            {value}
          </button>
        ))}
      </section>
      <button onClick={reset}>New game</button>
    </main>
  );
}

Flutter game logic

Flutter — main.dart core
class GameRules {
  static const lines = [
    [0,1,2], [3,4,5], [6,7,8],
    [0,3,6], [1,4,7], [2,5,8],
    [0,4,8], [2,4,6]
  ];

  static String? findWinner(List<String> board) {
    for (final line in lines) {
      if (board[line[0]].isNotEmpty &&
          board[line[0]] == board[line[1]] &&
          board[line[0]] == board[line[2]]) {
        return board[line[0]];
      }
    }
    return null;
  }
}

void play(int index) {
  if (board[index].isNotEmpty || winner != null) return;
  setState(() {
    board[index] = xTurn ? 'X' : 'O';
    xTurn = !xTurn;
    winner = GameRules.findWinner(board);
  });
}

Run and publish

React
npm install
npm test
npm run dev
git add .
git commit -m "Add React Tic-Tac-Toe with CI/CD"
git push origin main
Flutter
flutter create --platforms=android .
flutter pub get
flutter analyze
flutter test
flutter run
git add .
git commit -m "Add Flutter Tic-Tac-Toe with Android workflow"
git push origin main

Troubleshooting

The workflow is not visible

Confirm the YAML file is committed under the exact .github/workflows/ directory.

npm ci fails

Run npm install locally and commit the resulting package-lock.json.

The React page is blank or returns 404

Confirm the Vite base matches the repository name and that Pages uses GitHub Actions as its source.

Flutter cannot find the Android project

Run flutter create --platforms=android . and commit the generated android/ directory.

The APK artifact is missing

Open the build logs first. Check that the build succeeded and that the artifact path is build/app/outputs/flutter-apk/app-release.apk.

Student challenges

  1. Make the Hello workflow manual-only.
  2. Print the branch name and commit SHA.
  3. Break a test deliberately and explain why deployment stops.
  4. Add a turn counter to both games.
  5. Highlight the winning three cells.
  6. Preserve scores across several rounds.
  7. Add a Flutter widget test that produces an X win.
  8. Create and push a v1.0.0 tag.
  9. Protect main so CI must pass before merge.
  10. Advanced: sign production Android builds using protected secrets.

Official references

Learn With Champak
Build it. Break it. Let the workflow explain what happened. Fix it—and automate the lesson.