Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
version: 2
updates:
# Keep the third-party actions pinned inside this repo's composite actions and reusable
# workflows up to date. Dependabot opens bump PRs (preserving SHA pins) when new versions ship.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
commit-message:
prefix: "chore(actions)"
groups:
actions:
patterns:
- "*"

# Consumer repos should add the same `github-actions` ecosystem block so Dependabot tracks their
# `aragon/github-templates/...@<sha>` pins and auto-opens PRs to adopt new releases. That is how a
# security patch here propagates quickly while every consumer stays SHA-pinned.
176 changes: 176 additions & 0 deletions .github/workflows/_selftest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# Smoke test for the composite actions in this repo. Manual-dispatch only. Exercises the leaf
# actions that need no secrets — proving they load and produce outputs — via relative ./steps/*
# paths (valid here because this workflow runs in-repo, not via workflow_call).

name: Self-test (steps)

on:
workflow_dispatch:

permissions:
contents: read

jobs:
steps:
runs-on: ubuntu-latest
steps:
- name: Setup (install disabled — no package in this repo)
uses: ./steps/setup
with:
install: "false"

- name: extract-slack-ts
id: ets
uses: ./steps/extract-slack-ts
with:
body: "Some PR body\n<!-- slack_ts: 1700000000.123456 -->"

- name: assert extract-slack-ts
env:
TS: ${{ steps.ets.outputs.ts }}
run: |
set -euo pipefail
[ "$TS" = "1700000000.123456" ] || { echo "::error::extract-slack-ts failed: '$TS'"; exit 1; }

- name: build-release-notes
id: notes
uses: ./steps/build-release-notes
with:
changes: "- a change"
slack-ts: "1700000000.123456"

- name: assert build-release-notes
env:
NOTES_PATH: ${{ steps.notes.outputs.path }}
run: |
set -euo pipefail
grep -q "slack_ts: 1700000000.123456" "$NOTES_PATH" || { echo "::error::notes missing marker"; exit 1; }

- name: make a fake playwright report
run: |
mkdir -p e2e/test-results
echo '{"stats":{"expected":5,"flaky":1,"unexpected":0,"skipped":0}}' > e2e/test-results/results.json

- name: parse-playwright-results
id: pw
uses: ./steps/parse-playwright-results
with:
report-path: e2e/test-results/results.json
step-outcome: success

- name: assert parse-playwright-results
env:
RESULT: ${{ steps.pw.outputs.result }}
SUMMARY: ${{ steps.pw.outputs.summary }}
run: |
set -euo pipefail
[ "$RESULT" = "flaky" ] || { echo "::error::expected flaky, got '$RESULT'"; exit 1; }
echo "summary: $SUMMARY"

- name: generate-release-summary (git log of this repo)
uses: ./steps/generate-release-summary

# credential-retrieval is the only action that talks to 1Password, so it needs a real
# OP_SERVICE_ACCOUNT_TOKEN to exercise the happy path (out of scope for this secret-free
# self-test — see the module's own description). The cases below don't need one: they either
# fail during input validation (before any `op` call) or, for the "optional secret" case,
# never call `op` at all, so a placeholder token is enough.

- name: credential-retrieval — reject unknown mode
id: cr-bad-mode
continue-on-error: true
uses: ./steps/credential-retrieval
with:
mode: not-a-real-mode
op-token: "placeholder"

- name: assert unknown mode is rejected
env:
OUTCOME: ${{ steps.cr-bad-mode.outcome }}
run: |
set -euo pipefail
[ "$OUTCOME" = "failure" ] || { echo "::error::expected failure, got '$OUTCOME'"; exit 1; }

- name: credential-retrieval — reject missing op-vault (alltoenv)
id: cr-missing-vault
continue-on-error: true
uses: ./steps/credential-retrieval
with:
mode: alltoenv
op-token: "placeholder"
op-vault: ""

- name: assert missing op-vault is rejected
env:
OUTCOME: ${{ steps.cr-missing-vault.outcome }}
run: |
set -euo pipefail
[ "$OUTCOME" = "failure" ] || { echo "::error::expected failure, got '$OUTCOME'"; exit 1; }

- name: credential-retrieval — reject malformed secret-refs entry (missing '=')
id: cr-malformed
continue-on-error: true
uses: ./steps/credential-retrieval
with:
mode: byref
op-token: "placeholder"
secret-refs: |
THIS_LINE_HAS_NO_EQUALS_SIGN

- name: assert malformed secret-refs entry is rejected
env:
OUTCOME: ${{ steps.cr-malformed.outcome }}
run: |
set -euo pipefail
[ "$OUTCOME" = "failure" ] || { echo "::error::expected failure, got '$OUTCOME'"; exit 1; }

- name: credential-retrieval — reject invalid secret name (byref)
id: cr-bad-name
continue-on-error: true
uses: ./steps/credential-retrieval
with:
mode: byref
op-token: "placeholder"
secret-refs: |
1INVALID=op://vault/item/field

- name: assert invalid secret name is rejected
env:
OUTCOME: ${{ steps.cr-bad-name.outcome }}
run: |
set -euo pipefail
[ "$OUTCOME" = "failure" ] || { echo "::error::expected failure, got '$OUTCOME'"; exit 1; }

- name: credential-retrieval — reject invalid op:// reference (byref)
id: cr-bad-ref
continue-on-error: true
uses: ./steps/credential-retrieval
with:
mode: byref
op-token: "placeholder"
secret-refs: |
SOME_TOKEN=not-an-op-reference

- name: assert invalid op:// reference is rejected
env:
OUTCOME: ${{ steps.cr-bad-ref.outcome }}
run: |
set -euo pipefail
[ "$OUTCOME" = "failure" ] || { echo "::error::expected failure, got '$OUTCOME'"; exit 1; }

- name: credential-retrieval — optional (empty) secret resolves without error
id: cr-optional
uses: ./steps/credential-retrieval
with:
mode: byref
op-token: "placeholder"
secret-refs: |
OPTIONAL_TOKEN=

- name: assert optional secret resolves to an empty string
env:
SECRETS_JSON: ${{ steps.cr-optional.outputs.secrets }}
run: |
set -euo pipefail
VALUE="$(jq -r '.OPTIONAL_TOKEN' <<< "$SECRETS_JSON")"
[ "$VALUE" = "" ] || { echo "::error::expected empty string, got '$VALUE'"; exit 1; }
122 changes: 122 additions & 0 deletions .github/workflows/deploy-docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Reusable build-on-server Docker deploy over SSH. Generic transport + environment gate; the repo
# supplies its own remote deploy command (e.g. scripts/app-deploy.sh) because that orchestration is
# service-specific. SSH credentials are resolved from 1Password paths only inside this workflow.
#
# SELF-REFERENCE NOTE: `aragon/github-templates/steps/*@main` — see e2e.yml header.

name: Deploy (Docker over SSH)

on:
workflow_call:
inputs:
env:
description: "Target environment (drives the GitHub environment gate)"
required: true
type: string
ref:
description: "Branch, tag or SHA to deploy"
required: false
type: string
allow-unsafe-pr-checkout:
description: |
Pass-through to actions/checkout's `allow-unsafe-pr-checkout`. Only set this to true if
THIS workflow is called from a `pull_request_target` / `workflow_run` trigger that
intentionally deploys fork PR code via `ref`, and the risk at
https://gh.io/securely-using-pull_request_target has been reviewed. Defaults to false.
required: false
type: boolean
default: false
remote-path:
description: "Directory on the server to sync the repo into"
required: false
type: string
default: "~/app"
deploy-command:
description: "Command executed on the server, inside remote-path, after sync"
required: true
type: string
op-ssh-key-path:
description: "1Password path to the SSH private key"
required: true
type: string
op-ssh-user-path:
description: "1Password path to the SSH user"
required: true
type: string
op-ssh-host-path:
description: "1Password path to the SSH host"
required: true
type: string
secrets:
OP_SERVICE_ACCOUNT_TOKEN:
description: "1Password service account token scoped to the caller repo's vault"
required: true

permissions:
contents: read

concurrency:
group: deploy-${{ github.repository }}-${{ inputs.env }}
cancel-in-progress: false

jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.env }}
steps:
# The only 1Password call in this workflow — see steps/credential-retrieval. Op:// format
# validation now lives there too, so this no longer needs its own copy of that regex.
- name: Resolve SSH credentials
id: op
uses: aragon/github-templates/steps/credential-retrieval@main
with:
mode: byref
op-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
secret-refs: |
SSH_KEY=${{ inputs.op-ssh-key-path }}
SSH_USER=${{ inputs.op-ssh-user-path }}
SSH_HOST=${{ inputs.op-ssh-host-path }}

- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
fetch-depth: 1
allow-unsafe-pr-checkout: ${{ inputs.allow-unsafe-pr-checkout }}

- name: Configure SSH
env:
SSH_KEY: ${{ fromJSON(steps.op.outputs.secrets).SSH_KEY }}
SSH_HOST: ${{ fromJSON(steps.op.outputs.secrets).SSH_HOST }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null

- name: Sync repo to server
env:
SSH_USER: ${{ fromJSON(steps.op.outputs.secrets).SSH_USER }}
SSH_HOST: ${{ fromJSON(steps.op.outputs.secrets).SSH_HOST }}
REMOTE_PATH: ${{ inputs.remote-path }}
run: |
set -euo pipefail
ssh -i ~/.ssh/deploy_key "${SSH_USER}@${SSH_HOST}" "mkdir -p ${REMOTE_PATH}"
rsync -az --delete --exclude '.git' -e "ssh -i ~/.ssh/deploy_key" \
./ "${SSH_USER}@${SSH_HOST}:${REMOTE_PATH}/"

- name: Run remote deploy command
env:
SSH_USER: ${{ fromJSON(steps.op.outputs.secrets).SSH_USER }}
SSH_HOST: ${{ fromJSON(steps.op.outputs.secrets).SSH_HOST }}
REMOTE_PATH: ${{ inputs.remote-path }}
DEPLOY_COMMAND: ${{ inputs.deploy-command }}
run: |
set -euo pipefail
ssh -i ~/.ssh/deploy_key "${SSH_USER}@${SSH_HOST}" \
"cd ${REMOTE_PATH} && ${DEPLOY_COMMAND}"

- name: Cleanup SSH key
if: always()
run: rm -f ~/.ssh/deploy_key
Loading