| 1 | name: deploy |
| 2 | on: |
| 3 | # Auto-deploy on every push to trunk, but only after CI succeeds — |
| 4 | # workflow_run waits for the named workflow to complete. |
| 5 | workflow_run: |
| 6 | workflows: [ci] |
| 7 | branches: [trunk] |
| 8 | types: [completed] |
| 9 | # Escape hatch: redeploy current trunk without pushing. |
| 10 | workflow_dispatch: |
| 11 | |
| 12 | permissions: |
| 13 | contents: read |
| 14 | |
| 15 | # GH Actions is migrating its built-in Node from 20 to 24 by 2026-09-16. |
| 16 | # Force our pinned action versions onto Node 24 now so the deprecation |
| 17 | # warning clears and the eventual default flip is a no-op for us. Drop |
| 18 | # this once every action below is bumped to a Node-24-native release. |
| 19 | env: |
| 20 | FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" |
| 21 | |
| 22 | concurrency: |
| 23 | # Serialize deploys so two pushes in flight don't race on the |
| 24 | # remote git reset / binary swap. |
| 25 | group: deploy-prod |
| 26 | cancel-in-progress: false |
| 27 | |
| 28 | jobs: |
| 29 | deploy: |
| 30 | # Skip when the upstream CI run failed; workflow_dispatch always runs. |
| 31 | if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} |
| 32 | runs-on: ubuntu-latest |
| 33 | environment: production |
| 34 | steps: |
| 35 | - uses: actions/checkout@v4 |
| 36 | with: |
| 37 | # Full history so the Mirror-to-shithub.sh step can prove |
| 38 | # ancestry against whatever the mirror's tip is. Default |
| 39 | # depth=1 makes any push that isn't the very first commit |
| 40 | # fail "non-fast-forward" because the runner can't see |
| 41 | # the parent chain. |
| 42 | fetch-depth: 0 |
| 43 | |
| 44 | - uses: actions/setup-go@v5 |
| 45 | with: |
| 46 | go-version-file: go.mod |
| 47 | cache: true |
| 48 | |
| 49 | # Delegate to `make build` so the version-injection ldflags |
| 50 | # (Version/Commit/BuiltAt → internal/version) are the same set |
| 51 | # the local Makefile uses. The previous inline `go build` here |
| 52 | # passed only `-s -w`, which left the homepage stamps showing |
| 53 | # "dev (unknown, built unknown)" on every deployed binary. |
| 54 | # The runner is linux/amd64 so no cross-compile is needed. |
| 55 | # Output goes to ./shithubd (overriding BIN) so the next step's |
| 56 | # `< shithubd` redirect picks it up. |
| 57 | - name: Build shithubd |
| 58 | env: |
| 59 | CGO_ENABLED: "0" |
| 60 | run: make build BIN=shithubd |
| 61 | |
| 62 | - name: Configure SSH |
| 63 | env: |
| 64 | DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} |
| 65 | DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} |
| 66 | run: | |
| 67 | mkdir -p ~/.ssh |
| 68 | chmod 700 ~/.ssh |
| 69 | printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519 |
| 70 | printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts |
| 71 | chmod 600 ~/.ssh/id_ed25519 ~/.ssh/known_hosts |
| 72 | |
| 73 | - name: Ship binary |
| 74 | env: |
| 75 | DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} |
| 76 | DEPLOY_USER: ${{ secrets.DEPLOY_USER }} |
| 77 | # Stream over an exec channel rather than scp/sftp — the |
| 78 | # hardened sshd on the droplet has the sftp-server subsystem |
| 79 | # disabled, and modern scp insists on sftp. |
| 80 | run: | |
| 81 | ssh -o BatchMode=yes "${DEPLOY_USER}@${DEPLOY_HOST}" \ |
| 82 | 'cat > /tmp/shithubd-new && chmod 0755 /tmp/shithubd-new' < shithubd |
| 83 | |
| 84 | - name: Redeploy |
| 85 | env: |
| 86 | DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} |
| 87 | DEPLOY_USER: ${{ secrets.DEPLOY_USER }} |
| 88 | run: | |
| 89 | ssh -o BatchMode=yes "${DEPLOY_USER}@${DEPLOY_HOST}" \ |
| 90 | 'cd /root/src/shithub && git fetch --quiet origin trunk && git reset --hard origin/trunk && bash deploy/redeploy.sh' |
| 91 | |
| 92 | # Mirror the just-deployed SHA to the self-hosted shithub.sh |
| 93 | # instance so anyone reading the source on shithub.sh sees the |
| 94 | # same tip as github. Runs only after the redeploy succeeds, so |
| 95 | # a broken commit never appears canonical on the dogfood mirror. |
| 96 | # The credential helper feeds the PAT via stdin to avoid leaking |
| 97 | # it via the process listing or git's url-with-credentials log. |
| 98 | # |
| 99 | # Plain `push` (no --force-with-lease): if shithub.sh ever |
| 100 | # diverges from origin/trunk, the push fails non-fast-forward |
| 101 | # and a human reconciles. We never want a runner to silently |
| 102 | # overwrite a human edit on the mirror. |
| 103 | # |
| 104 | # The app may still be coming back through Caddy immediately |
| 105 | # after the systemd restart. Wait for the public HTTP surface |
| 106 | # and retry the mirror push so a transient 502 does not mark a |
| 107 | # successful deploy as failed. Persistent auth, divergence, or |
| 108 | # server errors still fail the job. |
| 109 | - name: Mirror to shithub.sh |
| 110 | if: success() |
| 111 | env: |
| 112 | SHITHUB_PUSH_USER: ${{ secrets.SHITHUB_PUSH_USER }} |
| 113 | SHITHUB_PUSH_PAT: ${{ secrets.SHITHUB_PUSH_PAT }} |
| 114 | run: | |
| 115 | curl --fail --silent --show-error \ |
| 116 | --retry 6 --retry-delay 5 --retry-all-errors \ |
| 117 | https://shithub.sh/ \ |
| 118 | --output /dev/null |
| 119 | |
| 120 | mirror_push() { |
| 121 | git -c "credential.helper=!f() { echo username=$SHITHUB_PUSH_USER; echo password=$SHITHUB_PUSH_PAT; }; f" \ |
| 122 | push \ |
| 123 | https://shithub.sh/tenseleyflow/shithub.git \ |
| 124 | HEAD:trunk |
| 125 | } |
| 126 | |
| 127 | status=0 |
| 128 | for attempt in 1 2 3 4 5; do |
| 129 | if mirror_push; then |
| 130 | exit 0 |
| 131 | fi |
| 132 | status=$? |
| 133 | if [ "$attempt" -eq 5 ]; then |
| 134 | break |
| 135 | fi |
| 136 | sleep "$((attempt * 10))" |
| 137 | done |
| 138 | exit "$status" |
| 139 |