| author | Alan Dipert
<alan@tailrecursion.com> 2026-11-11 17:59:28 UTC |
| committer | Alan Dipert
<alan@tailrecursion.com> 2026-11-11 17:59:28 UTC |
| parent | 1b2887714996d0056d764b2cd59f702ff58a4110 |
| .forgejo/known_hosts | +1 | -0 |
| .forgejo/workflows/ci.yml | +23 | -9 |
| .forgejo/workflows/deploy.yml | +17 | -0 |
| README.md | +9 | -7 |
| tests/test_deploy.py | +93 | -0 |
| tools/deploy.py | +88 | -0 |
diff --git a/.forgejo/known_hosts b/.forgejo/known_hosts new file mode 100644 index 0000000..53665d7 --- /dev/null +++ b/.forgejo/known_hosts @@ -0,0 +1 @@ +pdx1-shared-a2-13.dreamhost.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIChdoBJBUsNu2gAH//Ls33NGdtn4ZXkIKxh9jdCSSOFS diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index d5c8c0e..10022f3 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -4,6 +4,19 @@ on: push: pull_request: workflow_dispatch: + workflow_call: + inputs: + deploy: + type: boolean + default: false + dry_run: + type: boolean + default: false + secrets: + SITE_DEPLOY_KEY: + required: true + PUBLISH_TOKEN: + required: true jobs: check: @@ -22,6 +35,11 @@ jobs: export GIT_CONFIG_VALUE_0="Authorization: Basic $auth" git fetch --no-tags --depth=1 origin "$GITHUB_SHA" git checkout --detach FETCH_HEAD + - name: Verify deployment source + if: inputs.deploy + env: + CHECK_TOKEN: ${{ github.token }} + run: python3 tools/deploy.py check - name: Build pinned toolchain run: make container-image - name: Fetch website media with pinned Git LFS @@ -42,14 +60,10 @@ jobs: run: make verify-reproducible - name: Build website run: make container-build - - name: Request repository publication - if: github.event_name == 'push' && github.ref == 'refs/heads/master' + - name: Deploy website and request source publication + if: inputs.deploy env: + CHECK_TOKEN: ${{ github.token }} + SITE_DEPLOY_KEY: ${{ secrets.SITE_DEPLOY_KEY }} PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} - run: | - curl --fail-with-body --silent --show-error --config - \ - --header 'Content-Type: application/json' \ - --data "{\"ref\":\"master\",\"inputs\":{\"repository\":\"$GITHUB_REPOSITORY\",\"commit\":\"$GITHUB_SHA\",\"source_run\":\"$GITHUB_RUN_ID\"}}" \ - "$GITHUB_SERVER_URL/api/v1/repos/alan/repos-publisher/actions/workflows/publish.yml/dispatches" <<EOF - header = "Authorization: token $PUBLISH_TOKEN" - EOF + run: python3 tools/deploy.py deploy ${{ inputs.dry_run && '--dry-run' || '' }} diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..d6d1cdf --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -0,0 +1,17 @@ +name: Deploy + +on: + workflow_dispatch: + inputs: + dry_run: + description: Preview website changes without publishing the website or source mirror + type: boolean + default: false + +jobs: + deploy: + uses: ./.forgejo/workflows/ci.yml + with: + deploy: true + dry_run: ${{ inputs.dry_run }} + secrets: inherit diff --git a/README.md b/README.md index ebcf8fc..135020b 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ After changing the npm dependencies, refresh their lockfile with `npm install -- An underscore-prefixed directory also makes its pages unlisted: `md/_previews/Page.md` becomes `_previews/Page.html`. Keep the normal level-one title; the underscore belongs only in the path. Editor backups and lock files remain excluded. -Build with `make container-build`, inspect the HTML in `out/`, and deploy through the normal `make deploy` workflow. For example, `md/_MutableLocals.md` will be available at `https://tailrecursion.com/~alan/_MutableLocals.html` after deployment. This uses the ordinary static-site build and deployment, with no separate preview server. Forgejo's source-publishing job does not deploy the website. +Build with `make container-build`, inspect the HTML in `out/`, and deploy through the manual **Deploy** workflow in Forgejo. For example, `md/_MutableLocals.md` will be available at `https://tailrecursion.com/~alan/_MutableLocals.html` after deployment. This uses the ordinary static-site build and deployment, with no separate preview server. The manual deployment publishes both the website and its source mirror. Previews can link to published pages and to one another. They do not add backlinks to those pages. The build rejects links from listed pages to previews, including links in Home's Updates table, so previews cannot accidentally appear in the updates feed or Explore tiles. To publish a preview normally, remove the underscore and add its new path to the Updates table. @@ -81,13 +81,13 @@ Preview pages include `noindex` metadata for search engines. They are unlisted, ## Continuous integration and public source -Forgejo CI uses the pinned builder to run the tests, compare two independent builds, and build the website. It downloads only the LFS media needed by the website. Successful pushes to `master` request publication of the source at [tailrecursion.com/repos/](https://tailrecursion.com/repos/r/homepage.git/). +Forgejo CI uses the pinned builder to run the tests, compare two independent builds, and build the website. It downloads only the LFS media needed by the website. Pushes and pull requests run checks only. The manual **Deploy** workflow publishes the website and requests publication of its source at [tailrecursion.com/repos/](https://tailrecursion.com/repos/r/homepage.git/). ```sh git clone https://tailrecursion.com/repos/git/homepage.git ``` -The public mirror includes LFS objects referenced by the published history. For a smaller checkout without archive media, use `GIT_LFS_SKIP_SMUDGE=1 git clone ...`, followed by `git lfs pull --include='md/**' --exclude=''` when building the website. Source publication and website deployment are separate: CI does not run `make deploy`. +The public mirror includes LFS objects referenced by the published history. For a smaller checkout without archive media, use `GIT_LFS_SKIP_SMUDGE=1 git clone ...`, followed by `git lfs pull --include='md/**' --exclude=''` when building the website. The source mirror is built by the private `alan/repos-publisher` workflow after the website upload succeeds. ## Local fallback @@ -118,11 +118,13 @@ This check contacts external sites and is diagnostic; it is not part of the byte ## Deploy -```sh -make deploy -``` +In [Forgejo Actions](https://minerva/forgejo/alan/homepage/actions), run **Deploy** on `master`. The latest **Check** run must have succeeded for that exact commit. Select `dry_run` to build and preview the rsync changes without publishing either destination. + +The deployment reuses the CI build workflow and checks eligibility both before building and immediately before uploading. It publishes `out/` to [the website](https://tailrecursion.com/~alan/), then requests source publication under `/repos/`. The private publisher independently rechecks CI and finishes the mirror update in its own workflow. These are sequential deployments, not one atomic transaction; if source publication fails, the website may already be updated. Rerun **Deploy** after resolving the failure. + +The `SITE_DEPLOY_KEY` repository secret is restricted by the server's `rrsync` command to writes within the website directory. `.forgejo/known_hosts` pins the server's SSH host key. `PUBLISH_TOKEN` can dispatch the private source publisher. Build and test containers receive neither deployment credential; only the separate media-fetch container receives the short-lived checkout token. -Deploy first runs the reproducible container build, then uses host-side rsync and Git credentials to publish to `DEPLOY_HOST`. It refuses to run unless the Git worktree is clean and `HEAD` matches its upstream branch. The local build container never receives SSH keys or Git credentials. CI passes its short-lived checkout token only to a separate media-fetch container; the build and tests receive no credentials. +For a local website-only deployment, `make deploy` remains available. It runs the pinned container build, then uses host-side rsync and Git credentials to publish to `DEPLOY_HOST`. It requires a clean worktree with `HEAD` matching its upstream branch and does not request source publication. ## Preservation tools diff --git a/tests/test_deploy.py b/tests/test_deploy.py new file mode 100644 index 0000000..c6164b4 --- /dev/null +++ b/tests/test_deploy.py @@ -0,0 +1,93 @@ +import os +from pathlib import Path +import shlex +import subprocess +import unittest +from unittest.mock import patch + +import deploy + + +class DeployTests(unittest.TestCase): + def setUp(self): + self.enterContext(patch.dict(os.environ, { + 'GITHUB_REPOSITORY': 'alan/homepage', 'GITHUB_SHA': 'a' * 40, + 'GITHUB_REF': 'refs/heads/master', 'GITHUB_EVENT_NAME': 'workflow_dispatch', + 'CHECK_TOKEN': 'check-fixture', 'PUBLISH_TOKEN': 'publish-fixture', + 'SITE_DEPLOY_KEY': 'key-fixture'})) + self.run = {'id': 42, 'status': 'success', 'commit_sha': 'a' * 40, + 'prettyref': 'master', 'workflow_id': 'ci.yml', + 'event': '', 'trigger_event': 'workflow_dispatch'} + + def test_requires_the_latest_successful_master_check(self): + for change in ({}, {'status': 'failure'}, {'status': 'running'}, {'status': 'waiting'}, + {'status': 'skipped'}, {'status': 'cancelled'}, {'status': 'blocked'}, + {'status': 'unknown'}, {'commit_sha': 'b' * 40}, {'prettyref': 'branch'}, + {'workflow_id': 'deploy.yml'}, {'trigger_event': 'pull_request'}): + with self.subTest(change=change), patch('deploy.api', side_effect=[ + {'commit': {'id': 'a' * 40}}, + {'workflow_runs': [self.run | change, self.run]}]): + if change: + with self.assertRaises(ValueError): + deploy.checked_source() + else: + self.assertEqual(deploy.checked_source(), 42) + for responses in ([{'commit': {'id': 'b' * 40}}], + [{'commit': {'id': 'a' * 40}}, {'workflow_runs': []}]): + with self.subTest(responses=responses), patch('deploy.api', side_effect=responses): + with self.assertRaises(ValueError): + deploy.checked_source() + for change in ({'GITHUB_REF': 'refs/heads/branch'}, {'GITHUB_EVENT_NAME': 'push'}): + with patch.dict(os.environ, change), patch('deploy.api') as api: + with self.assertRaises(ValueError): + deploy.checked_source() + api.assert_not_called() + + def test_dry_run_and_deploy_keep_credentials_private_and_order_effects(self): + for dry_run in (True, False): + events, keys = [], [] + + def check(): + events.append('check') + return 42 + + def upload(command, **kwargs): + events.append('upload') + self.assertEqual('--dry-run' in command, dry_run) + ssh = shlex.split(command[command.index('-e') + 1]) + key = Path(ssh[ssh.index('-i') + 1]) + keys.append(key) + self.assertEqual(key.stat().st_mode & 0o777, 0o600) + self.assertEqual(key.read_text(), 'key-fixture\n') + self.assertIn('StrictHostKeyChecking=yes', ssh) + + with self.subTest(dry_run=dry_run), patch('deploy.checked_source', side_effect=check), \ + patch('deploy.Path.is_file', return_value=True), \ + patch('deploy.subprocess.run', side_effect=upload), \ + patch('deploy.api', side_effect=lambda *args: events.append('publish')) as api: + deploy.deploy(dry_run=dry_run) + self.assertEqual(events, ['check', 'upload'] if dry_run else + ['check', 'upload', 'check', 'publish']) + self.assertTrue(all(not key.exists() for key in keys)) + if not dry_run: + self.assertEqual(api.call_args.args[2], {'ref': 'master', 'inputs': { + 'repository': 'alan/homepage', 'commit': 'a' * 40, 'source_run': '42'}}) + + def test_failure_never_dispatches_source_publication(self): + for checks, exists, upload_error in (([ValueError('CI failed')], True, None), + ([42], False, None), ([42], True, subprocess.CalledProcessError(1, 'rsync')), + ([42, ValueError('master moved')], True, None), ([42, 43], True, None)): + with self.subTest(checks=checks, exists=exists, upload_error=upload_error), \ + patch('deploy.checked_source', side_effect=checks), \ + patch('deploy.Path.is_file', return_value=exists), \ + patch('deploy.subprocess.run', side_effect=upload_error) as upload, \ + patch('deploy.api') as api: + with self.assertRaises((ValueError, subprocess.CalledProcessError)): + deploy.deploy() + api.assert_not_called() + if not exists or isinstance(checks[0], Exception): + upload.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/deploy.py b/tools/deploy.py new file mode 100644 index 0000000..3915f70 --- /dev/null +++ b/tools/deploy.py @@ -0,0 +1,88 @@ +"""Deploy a tested master revision, then request its public source mirror.""" + +import argparse +import json +import os +from pathlib import Path +import shlex +import subprocess +import tempfile +import urllib.parse +import urllib.request + + +def api(path, token, body=None): + request = urllib.request.Request( + os.environ['GITHUB_SERVER_URL'] + '/api/v1/repos/' + path, + data=None if body is None else json.dumps(body).encode(), + headers={'Authorization': 'token ' + token, 'Content-Type': 'application/json'}) + with urllib.request.urlopen(request, timeout=30) as response: + return None if response.status == 204 else json.load(response) + + +def checked_source(): + repository, commit, ref = (os.environ[key] for key in + ('GITHUB_REPOSITORY', 'GITHUB_SHA', 'GITHUB_REF')) + if ref != 'refs/heads/master' or os.environ['GITHUB_EVENT_NAME'] != 'workflow_dispatch': + raise ValueError('Deploy must be run manually on master') + token = os.environ['CHECK_TOKEN'] + if api(repository + '/branches/master', token)['commit']['id'] != commit: + raise ValueError('master moved; start a new deployment') + query = urllib.parse.urlencode({'head_sha': commit, 'ref': ref, + 'workflow_id': 'ci.yml', 'limit': 1}) + runs = api(repository + '/actions/runs?' + query, token)['workflow_runs'] + # Forgejo orders runs newest first. A newer failure must supersede an old success. + if not runs or runs[0]['status'] != 'success': + raise ValueError('The latest CI run for this master commit must have succeeded') + run = runs[0] + # trigger_event also identifies manual runs; event only describes webhooks. + if ((run['commit_sha'], run['prettyref'], run['workflow_id']) != (commit, 'master', 'ci.yml') + or run['trigger_event'] not in ('push', 'schedule', 'workflow_dispatch')): + raise ValueError('CI run does not match this master commit') + return run['id'] + + +def deploy(*, dry_run=False): + source_run = checked_source() + if not Path('out/Home.html').is_file(): + raise ValueError('Build the website before deploying') + key_text = os.environ['SITE_DEPLOY_KEY'].strip() + publish_token = os.environ['PUBLISH_TOKEN'] + if not key_text or not publish_token: + raise ValueError('Both website and source-publisher credentials are required') + with tempfile.TemporaryDirectory(prefix='homepage-deploy-') as private: + key = Path(private) / 'key' + key.write_text(key_text + '\n') + key.chmod(0o600) + hosts = Path(__file__).resolve().parents[1] / '.forgejo/known_hosts' + ssh = ['ssh', '-F', '/dev/null', '-i', str(key), '-o', 'IdentitiesOnly=yes', + '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', + '-o', 'UserKnownHostsFile=' + str(hosts), '-o', 'ConnectTimeout=20'] + # The server's forced rrsync command confines this key to the website directory. + command = ['rsync', '-rtp', '--delete', '--delay-updates', '--chmod=F644,D755', '--stats', + '-e', shlex.join(ssh)] + if dry_run: + command += ['--dry-run', '--itemize-changes'] + subprocess.run([*command, 'out/', 'arsien23i2@pdx1-shared-a2-13.dreamhost.com:./'], + check=True) + if dry_run: + print('Dry run complete: website and public source mirror unchanged.') + return + if checked_source() != source_run: + raise ValueError('Source CI run changed during upload; start a new deployment') + api('alan/repos-publisher/actions/workflows/publish.yml/dispatches', publish_token, + {'ref': 'master', 'inputs': {'repository': os.environ['GITHUB_REPOSITORY'], + 'commit': os.environ['GITHUB_SHA'], + 'source_run': str(source_run)}}) + print('Website deployed. Source mirror publication requested in alan/repos-publisher.') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=['check', 'deploy']) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + if args.action == 'check': + print('Successful source CI run:', checked_source()) + else: + deploy(dry_run=args.dry_run)