diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb5bec1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,129 @@ +name: CI + +# Validation runs on everything. Rendering, committing and publishing are kept +# to main, where the code is already merged. +# +# The `if` on publish is load-bearing, not a formality: a pull request from a +# branch in this repository gets whatever permissions the workflow asks for, so +# without it every such branch would deploy the site and hold a write token +# while still unmerged. +# Fork pull requests are stopped by something else, GitHub forcing their token +# read-only, which no setting on a public repository can undo. + +on: + # Pull requests cover branch work, so pushes are only watched on main, where + # a change can land without one. Listing both events unfiltered would run + # everything twice for a pull request opened from a branch in this repo. + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: {} + +defaults: + run: + shell: bash # for -o pipefail, so a failure piped through tee still fails + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + concurrency: + group: verify-${{ github.ref }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: {python-version: '3.12'} + + - name: Validate the solution + run: python solution/validate_solution.py | tee -a "$GITHUB_STEP_SUMMARY" + + publish: + needs: verify + # An allowlist rather than "not a pull request": a trigger added later has + # to be named here before it can reach any of the write permissions below. + if: >- + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: write + pages: write + id-token: write + # No cancel-in-progress: a deployment should finish rather than be killed + # part way. A later run waits, though only one waits at a time, since a + # third run replaces the pending second. + concurrency: + group: publish + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: {python-version: '3.12'} + - run: pip install --quiet protobuf + + - name: Render the representations + id: render + run: | + python representations/webclient/render.py + python representations/protobuf/render.py + + # Deploying here rather than from a separate workflow publishes the files + # this job just rendered. A push made with GITHUB_TOKEN starts no further + # run, so a deploy triggered by the commit below would never happen. + - uses: actions/configure-pages@v5 + - name: Assemble the site + run: | + # index.html links to "explanation" and the explanation pages link + # back with "../", so the two are served one level apart. + mkdir _site + cp sample.png _site/ + cp -r representations/webclient/. _site/ + cp -r explanation _site/explanation + # The browser never fetches the scripts that build the page, or the + # layout file they read. + rm -rf _site/render.py _site/spread_graph.py _site/__pycache__ \ + _site/positions.txt + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + - id: deployment + uses: actions/deploy-pages@v4 + + # Last, so publishing never waits on the push. Conditioned on the render + # rather than on the steps above, so a Pages failure does not throw the + # rendered files away, and a half-written render is never committed. + - name: Commit whatever changed + if: ${{ !cancelled() && steps.render.outcome == 'success' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # Staged before the check, so a newly generated file counts too. + git add representations/ + if git diff --cached --quiet; then + echo "artifacts already up to date" + exit 0 + fi + git commit -m "Rebuild representations" + # A commit landing on main while this ran rejects the push. That + # commit's own run renders the same files, so the next run fixes it. + git push + + # Deliberately not conditioned on the step above: if that step stops + # running, through a renamed id or an edited condition, it skips in + # silence and the render is thrown away on a green run. + - name: Fail if the render was left uncommitted + if: ${{ !cancelled() }} + run: | + if [ -n "$(git status --porcelain -- representations/)" ]; then + git status --short -- representations/ + echo "::error::the render changed files that were not committed" + exit 1 + fi diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml deleted file mode 100644 index 0ec6441..0000000 --- a/.github/workflows/pages.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Publish GitHub Pages - -# Publishes the site at https://2swap.github.io/WeakC4/, from the tree as it -# stood at the commit that triggered the run. -# -# That tree can carry artifacts one rebuild behind. rebuild.yml commits the -# corrected graph.js afterwards, and a GITHUB_TOKEN push starts no further -# workflow run, so those corrected artifacts sit in main until the next push -# publishes them. -# -# The site is representations/webclient/ with explanation/ mounted beneath it: -# index.html links to "explanation" and the explanation pages link back with -# "../", so they have to be served one level apart. - -on: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: false - -jobs: - deploy: - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - uses: actions/checkout@v7 - - uses: actions/configure-pages@v5 - - name: Assemble the site - run: | - mkdir _site - cp sample.png _site/ - cp -r representations/webclient/. _site/ - cp -r explanation _site/explanation - # webclient/ also holds the scripts that generate the page and the - # layout file they work on, none of which the browser fetches. - rm -rf _site/render.py _site/spread_graph.py _site/__pycache__ \ - _site/positions.txt - - uses: actions/upload-pages-artifact@v3 - with: - path: _site - - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/rebuild.yml b/.github/workflows/rebuild.yml deleted file mode 100644 index 92f40de..0000000 --- a/.github/workflows/rebuild.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Rebuild representations - -# Runs after anything lands on main that can change a representation: -# validates the whole solution/ graph, regenerates whichever representations -# need it, and commits any changed artifacts. -# -# It runs here rather than inside the pull request on purpose. A workflow -# triggered by a fork's pull request would need write access to push the -# rebuilt files back, and granting that would let an untrusted branch run its -# own code with write permissions. Here the code has already been merged. - -on: - push: - branches: [main] - paths: - - solution/branches.json - - solution/steady_states.json - - representations/webclient/** - - representations/protobuf/** - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: rebuild-main - -defaults: - run: - shell: bash - -jobs: - rebuild: - runs-on: ubuntu-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@v7 - with: {fetch-depth: 0} - - uses: actions/setup-python@v7 - with: {python-version: '3.12'} - - run: pip install --quiet protobuf - - - name: Verify the whole graph - run: python solution/validate_solution.py | tee -a "$GITHUB_STEP_SUMMARY" - - - name: Render webclient - run: python representations/webclient/render.py - - - name: Render protobuf - run: python representations/protobuf/render.py - - - name: Commit if changed - run: | - if git diff --quiet -- representations/; then - echo "artifacts already up to date" - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add representations/ - git commit -m "Rebuild representations" - # A commit landing on main during the rebuild rejects this push. If it - # did not touch the sources above, these artifacts are still correct - # and rebasing publishes them; if it did, it queued its own rebuild - # that supersedes this one. Without the retry the representations - # just stay stale until the next qualifying push. - for attempt in 1 2 3; do - if git push; then exit 0; fi - echo "push rejected, rebasing onto main (attempt $attempt)" - git pull --rebase origin main - done - git push diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml deleted file mode 100644 index 1087424..0000000 --- a/.github/workflows/verify.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Verify contributions - -# Validates the whole solution/ graph before a change lands, on both events so -# that a direct push to main is covered as well as a pull request. -# -# Read-only, which is what makes it safe on a fork's branch. rebuild.yml is -# separate because pushing artifacts back needs write access, and an untrusted -# branch must not run with that. - -on: [push, pull_request] - -permissions: - contents: read - -concurrency: - # A pull request from a branch here raises both events; keying on its number - # puts them in one group so the duplicate is cancelled. - group: verify-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -defaults: - run: - shell: bash # for -o pipefail, so a failure piped through tee still fails - -jobs: - verify: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 - with: {python-version: '3.12'} - - - name: Verify the whole graph - run: python solution/validate_solution.py | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c5c32a3..6ce685e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,10 +123,10 @@ python solution/print_statistics.py ## What CI does -Every push and pull request runs the whole-solution check, and the result -appears in the **Summary** panel of the run, linked from the Checks tab. After -a change lands on `main`, a second workflow re-renders the representations and -commits whatever changed, and a third publishes the site. +Every pull request runs the whole-solution check, and the result appears in the +**Summary** panel of the run, linked from the Checks tab. Once a change is on +`main`, the same workflow re-renders the representations, publishes the site, +and commits whatever the render changed. A first-time contribution needs a maintainer to approve the run, so an empty Checks tab at first is normal.