From 4638453376a565f3a93805386e71ee3739e9b760 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Thu, 27 Aug 2026 21:16:31 -0600 Subject: [PATCH] ci: add manual Namecheap secret sync workflow --- .github/workflows/namecheap-secret-sync.yml | 129 ++++++++++++++++++++ docs/namecheap-secret-sync.md | 56 +++++++++ 2 files changed, 185 insertions(+) create mode 100644 .github/workflows/namecheap-secret-sync.yml create mode 100644 docs/namecheap-secret-sync.md diff --git a/.github/workflows/namecheap-secret-sync.yml b/.github/workflows/namecheap-secret-sync.yml new file mode 100644 index 0000000..8f79fac --- /dev/null +++ b/.github/workflows/namecheap-secret-sync.yml @@ -0,0 +1,129 @@ +name: namecheap-secret-sync + +on: + workflow_dispatch: + +permissions: + contents: write + id-token: write + pull-requests: write + +concurrency: + group: namecheap-secret-sync + cancel-in-progress: false + +jobs: + sync: + # The OIDC trust policy is restricted to the default branch; fail closed if + # someone dispatches this workflow from another ref. + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + AWS_REGION: us-west-2 + SECRET_ID: xnoto-namecheap-api + SOPS_VERSION: v3.13.3 + SOPS_SHA256: e5bec3346a873ae91d871550f3e698c1aad962aff462a080e40f25fde17fef6b + AWS_ROLE_ARN: ${{ vars.NAMECHEAP_SECRET_SYNC_AWS_ROLE_ARN }} + SECRETS_KMS_KEY_ARN: ${{ vars.NAMECHEAP_SECRET_SYNC_SECRETS_KMS_KEY_ARN }} + steps: + - name: Check required configuration + shell: bash + run: | + set -euo pipefail + set +x + test -n "$AWS_ROLE_ARN" + test -n "$SECRETS_KMS_KEY_ARN" + + - name: Check out main + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: main + persist-credentials: true + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + aws-region: ${{ env.AWS_REGION }} + role-to-assume: ${{ env.AWS_ROLE_ARN }} + role-session-name: namecheap-secret-sync-${{ github.run_id }} + role-duration-seconds: 900 + allowed-account-ids: 332355796717 + mask-aws-account-id: true + unset-current-credentials: true + retry-max-attempts: 3 + action-timeout-s: 30 + inline-session-policy: >- + {"Version":"2012-10-17","Statement":[{"Sid":"ReadOnlyNamecheapSecret","Effect":"Allow","Action":"secretsmanager:GetSecretValue","Resource":"arn:aws:secretsmanager:us-west-2:332355796717:secret:xnoto-namecheap-api-*"},{"Sid":"DecryptOnlyRequiredKeys","Effect":"Allow","Action":"kms:Decrypt","Resource":["arn:aws:kms:us-west-2:332355796717:key/0a45c0f6-71dc-4d54-ab33-9df4de1a9e91","${{ env.SECRETS_KMS_KEY_ARN }}"]}]} + + - name: Install verified SOPS + id: sops + shell: bash + run: | + set -euo pipefail + set +x + sops_bin="$RUNNER_TEMP/sops-${SOPS_VERSION}" + curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + --output "$sops_bin" \ + "https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.amd64" + printf '%s %s\n' "$SOPS_SHA256" "$sops_bin" | sha256sum --check --status + chmod 0755 "$sops_bin" + printf 'path=%s\n' "$sops_bin" >> "$GITHUB_OUTPUT" + + - name: Update the allowlisted encrypted key + env: + SOPS_BIN: ${{ steps.sops.outputs.path }} + shell: bash + run: | + set -euo pipefail + set +x + umask 077 + + # Fail rather than create a new key; no decrypted value is emitted. + "$SOPS_BIN" --decrypt --extract '["namecheap_api_key"]' secrets/secrets.yaml >/dev/null + + namecheap_api_key="$(aws secretsmanager get-secret-value \ + --secret-id "$SECRET_ID" \ + --query SecretString \ + --output text)" + test -n "$namecheap_api_key" + + # --set edits the existing encrypted document in place, limiting the + # plaintext lifetime to this shell and avoiding decrypted files. + json_value="$(printf '%s' "$namecheap_api_key" | jq -Rsc '.')" + "$SOPS_BIN" --set '["namecheap_api_key"] '"$json_value" secrets/secrets.yaml + unset json_value namecheap_api_key + + - name: Create a scoped pull request + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + set +x + branch="automation/secret-sync-namecheap-api-${GITHUB_RUN_ID}" + + git add -- secrets/secrets.yaml + git diff --cached --check + if git diff --cached --quiet; then + printf '%s\n' 'The encrypted value is already current; no pull request was created.' + exit 0 + fi + + changed_files="$(git diff --cached --name-only)" + test "$changed_files" = "secrets/secrets.yaml" + git switch --create "$branch" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m 'chore: sync Namecheap API key' + git push --set-upstream origin "$branch" + + pr_url="$(gh pr create \ + --base main \ + --head "$branch" \ + --title 'chore: sync Namecheap API key' \ + --body 'Automated manual sync of the encrypted `namecheap_api_key` value. Review the encrypted-file diff before merge.')" + { + printf 'Created branch: `%s`\n\n' "$branch" + printf 'Created pull request: %s\n' "$pr_url" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/namecheap-secret-sync.md b/docs/namecheap-secret-sync.md new file mode 100644 index 0000000..ad75f13 --- /dev/null +++ b/docs/namecheap-secret-sync.md @@ -0,0 +1,56 @@ +# Namecheap API Key Secret Sync + +`.github/workflows/namecheap-secret-sync.yml` is a manual recovery/rotation workflow. It is intentionally available only through `workflow_dispatch`, runs on GitHub-hosted `ubuntu-24.04`, and creates a new scoped pull request instead of updating `main`. + +## Operator Procedure + +1. Confirm that the AWS secret named `xnoto-namecheap-api` has its complete Namecheap API key in `SecretString`; it must not be JSON-wrapped. +2. In this repository, select **Actions**, **namecheap-secret-sync**, and **Run workflow** from `main`. +3. Review the resulting `automation/secret-sync-namecheap-api-` pull request. The encrypted file should be the only changed file. Merge it only after normal branch-protection checks pass. +4. If the key is already current, the run exits without creating a branch or pull request. + +Do not add inputs to this workflow for secret identifiers or values. The workflow has a fixed allowlist for `xnoto-namecheap-api` and updates only the pre-existing `namecheap_api_key` path in `secrets/secrets.yaml`. It does not print or write decrypted secret material to a file. + +## AWS Prerequisites + +These resources are intentionally not managed by this OpenTofu root. Before dispatching the workflow, an AWS administrator must configure the following least-privilege prerequisites. + +Set these non-secret repository variables: + +- `NAMECHEAP_SECRET_SYNC_AWS_ROLE_ARN`: ARN of the dedicated OIDC role. +- `NAMECHEAP_SECRET_SYNC_SECRETS_KMS_KEY_ARN`: CMK ARN that encrypts the `xnoto-namecheap-api` Secrets Manager secret. + +The role trust policy must allow only GitHub's OIDC provider, audience `sts.amazonaws.com`, and this repository's `main` ref. For this existing repository, use this subject condition (replace only the account ID in the provider ARN): + +```json +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam::332355796717:oidc-provider/token.actions.githubusercontent.com" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + "token.actions.githubusercontent.com:sub": "repo:makeitworkcloud/tfroot-github:ref:refs/heads/main" + } + } + }] +} +``` + +If this repository has opted into GitHub's immutable OIDC subject claims, use its GitHub-provided immutable `sub` value instead of the legacy value above. Do not broaden the condition to an organization or branch wildcard. + +Attach a role policy limited to these resources: + +- `secretsmanager:GetSecretValue` for `arn:aws:secretsmanager:us-west-2:332355796717:secret:xnoto-namecheap-api-*`. +- `kms:Decrypt` for `arn:aws:kms:us-west-2:332355796717:key/0a45c0f6-71dc-4d54-ab33-9df4de1a9e91`, which is the SOPS key in `.sops.yaml`. +- `kms:Decrypt` for the CMK in `NAMECHEAP_SECRET_SYNC_SECRETS_KMS_KEY_ARN`. + +The workflow applies the same resource limits as an inline session policy, so the role must not rely on broader permissions. The Secrets Manager CMK key policy must also permit this role to decrypt that one secret. No other Secrets Manager secret, KMS key, or AWS action is required. + +## Supply Chain + +The workflow downloads the `linux.amd64` binary for SOPS `v3.13.3` directly from the official `getsops/sops` GitHub release and verifies SHA-256 `e5bec3346a873ae91d871550f3e698c1aad962aff462a080e40f25fde17fef6b` before execution. GitHub actions are commit-SHA pinned.