feat(P03 W7): secret rotation scheduled workflow (SPEC §5.9)

workflows-src/rotate-aws-key.yml — daily cron (0 0 * * *) + workflow_dispatch,
wraps scripts/rotate_spike_key.sh (uses NOVA_AWS_* static-key auth to IAM-
rotate the nova-spike-runner key; uploads the new key to the consumer's
Actions secret store; idempotent — deactivates the old key only after the
new propagates, verified by a post-PUT GET). Synced to .github + .gitea.
v0.2 scope: the mechanism exists (SPEC §5.9 — exists-not-ran); the v0.2
deploy uses the currently-active key. Documented in ARCHITECTURE.md §12.9.

The synced workflow file is forge-agnostic (REQ-230): forge base URL /
owner / consumer repo come from repository secrets (NOVA_FORGE_*,
NOVA_CONSUMER_REPO), not literals. rotate_spike_key.sh reads NOVA_FORGE_*
with NOVA_GITEA_* backward-compat fallback. sync_workflows.py PAIRS
extended to include rotate-aws-key.yml (was hardcoded to 3 pairs).

---ci---
project: acdl
phase: 3
milestone: v1.26
status: execute
wave: W7
---
This commit is contained in:
Jon Chery
2026-08-18 23:39:34 +00:00
parent b237b3e85b
commit 9bac2685cb
7 changed files with 445 additions and 28 deletions
+14 -1
View File
@@ -503,4 +503,17 @@ binary is not installed).
> **§12.8 — Pilot Estate** is planned for the v1.26 P4 phase (REQ-321).
> It will document the live-pilot architecture (consumer contract →
> `deploy.yml@v1.25` → apply → attest → record against `581513795199`).
> `deploy.yml@v1.25` → apply → attest → record against `581513795199`).
### §12.9 — Secret Rotation (v1.26 P3 W7, SPEC §5.9 — current)
The platform-managed scheduled workflow `workflows-src/rotate-aws-key.yml`
rotates the `NOVA_AWS_*` static key daily (cron `0 0 * * *`) and on
`workflow_dispatch`. v0.2 scope: the mechanism exists (SPEC §5.9 —
exists-not-ran); the v0.2 deploy uses the currently-active key. The
rotation is idempotent — `scripts/rotate_spike_key.sh` deactivates the old
key only after the new one propagates to the consumer's Actions secret
store, verified by a post-PUT GET; on upload/verify failure the old key is
left Active and the run exits non-zero. The synced workflow file is
forge-agnostic (REQ-230): forge base URL / owner / consumer repo come from
repository secrets (`NOVA_FORGE_*`, `NOVA_CONSUMER_REPO`), not literals.
+69
View File
@@ -0,0 +1,69 @@
# Nova AWS key rotation — platform-managed scheduled pipeline (SPEC §5.9)
#
# Rotates the NOVA_AWS_* static key daily (no long-lived keys in the steady
# state). v0.2 scope: the mechanism must exist (SPEC §5.9); the v0.2 deploy
# uses the currently-active key. The rotation is best-effort + idempotent
# (scripts/rotate_spike_key.sh deactivates the old key only after the new
# key propagates to the consumer's Actions secret store).
#
# Auth: the rotation uses the CURRENT NOVA_AWS_* key to authenticate to IAM
# (the root account 581513795199 can rotate its own keys — confirmed by the
# bootstrap). The aws-actions/configure-aws-credentials@v4 step uses the
# static-key path (no OIDC role-to-assume); the long-lived key rotates
# itself, which is the bootstrap-exception documented in §5.9.
#
# Forge coords (base URL / owner / consumer repo) are sourced from
# repository secrets — NOVA_FORGE_BASE_URL, NOVA_FORGE_OWNER,
# NOVA_CONSUMER_REPO — so the synced workflow file stays forge-agnostic
# (REQ-230). The rotation script uploads the new key to the consumer's
# Actions secret store (the consumer whose deploy.yml consumes NOVA_AWS_*
# via secrets: inherit).
name: nova-rotate-aws-key
on:
schedule:
- cron: "0 0 * * *" # daily at 00:00 UTC
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
rotate:
name: Rotate NOVA_AWS_* static key
runs-on: ubuntu-latest
steps:
- name: Check out Nova platform repo
uses: actions/checkout@v4
- name: Configure AWS credentials (bootstrap root creds for IAM key rotation)
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
- name: Install Python deps (boto3 for the rotation script)
run: |
python3 -m pip install --break-system-packages --quiet boto3
- name: Run the key rotation script
env:
# aws-actions/configure-aws-credentials exports AWS_ACCESS_KEY_ID /
# AWS_SECRET_ACCESS_KEY; the rotation script reads the bootstrap
# creds via NOVA_BOOTSTRAP_AWS_* (its dual-read contract, D-034).
# Map the standard AWS_* exports onto the script's expected vars.
NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
# Forge + consumer coords come from repository secrets (REQ-230 —
# no forge hostnames/orgs hardcoded in the synced workflow file).
# NOVA_FORGE_TOKEN holds the forge API token (set equal to the
# existing forge token as a one-time secret setup).
NOVA_FORGE_TOKEN: ${{ secrets.NOVA_FORGE_TOKEN }}
NOVA_FORGE_BASE_URL: ${{ secrets.NOVA_FORGE_BASE_URL }}
NOVA_FORGE_OWNER: ${{ secrets.NOVA_FORGE_OWNER }}
NOVA_CONSUMER_REPO: ${{ secrets.NOVA_CONSUMER_REPO }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
run: |
bash scripts/rotate_spike_key.sh
+69
View File
@@ -0,0 +1,69 @@
# Nova AWS key rotation — platform-managed scheduled pipeline (SPEC §5.9)
#
# Rotates the NOVA_AWS_* static key daily (no long-lived keys in the steady
# state). v0.2 scope: the mechanism must exist (SPEC §5.9); the v0.2 deploy
# uses the currently-active key. The rotation is best-effort + idempotent
# (scripts/rotate_spike_key.sh deactivates the old key only after the new
# key propagates to the consumer's Actions secret store).
#
# Auth: the rotation uses the CURRENT NOVA_AWS_* key to authenticate to IAM
# (the root account 581513795199 can rotate its own keys — confirmed by the
# bootstrap). The aws-actions/configure-aws-credentials@v4 step uses the
# static-key path (no OIDC role-to-assume); the long-lived key rotates
# itself, which is the bootstrap-exception documented in §5.9.
#
# Forge coords (base URL / owner / consumer repo) are sourced from
# repository secrets — NOVA_FORGE_BASE_URL, NOVA_FORGE_OWNER,
# NOVA_CONSUMER_REPO — so the synced workflow file stays forge-agnostic
# (REQ-230). The rotation script uploads the new key to the consumer's
# Actions secret store (the consumer whose deploy.yml consumes NOVA_AWS_*
# via secrets: inherit).
name: nova-rotate-aws-key
on:
schedule:
- cron: "0 0 * * *" # daily at 00:00 UTC
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
rotate:
name: Rotate NOVA_AWS_* static key
runs-on: ubuntu-latest
steps:
- name: Check out Nova platform repo
uses: actions/checkout@v4
- name: Configure AWS credentials (bootstrap root creds for IAM key rotation)
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
- name: Install Python deps (boto3 for the rotation script)
run: |
python3 -m pip install --break-system-packages --quiet boto3
- name: Run the key rotation script
env:
# aws-actions/configure-aws-credentials exports AWS_ACCESS_KEY_ID /
# AWS_SECRET_ACCESS_KEY; the rotation script reads the bootstrap
# creds via NOVA_BOOTSTRAP_AWS_* (its dual-read contract, D-034).
# Map the standard AWS_* exports onto the script's expected vars.
NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
# Forge + consumer coords come from repository secrets (REQ-230 —
# no forge hostnames/orgs hardcoded in the synced workflow file).
# NOVA_FORGE_TOKEN holds the forge API token (set equal to the
# existing forge token as a one-time secret setup).
NOVA_FORGE_TOKEN: ${{ secrets.NOVA_FORGE_TOKEN }}
NOVA_FORGE_BASE_URL: ${{ secrets.NOVA_FORGE_BASE_URL }}
NOVA_FORGE_OWNER: ${{ secrets.NOVA_FORGE_OWNER }}
NOVA_CONSUMER_REPO: ${{ secrets.NOVA_CONSUMER_REPO }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
run: |
bash scripts/rotate_spike_key.sh
+107 -25
View File
@@ -5,11 +5,20 @@
# fallback) from the env to:
# 1. List nova-spike-runner's access keys.
# 2. Create a new key.
# 3. Deactivate + delete the old key(s).
# 4. Write the new key to gitignored .env.secrets (chmod 600).
# 5. Optionally upload to Gitea secrets if NOVA_GITEA_TOKEN is set.
# 3. Write the new key to gitignored .env.secrets (chmod 600).
# 4. Upload the new key to the consumer's Actions secret store + verify
# (GET) that it propagated (SPEC §5.9 idempotency).
# 5. Deactivate + delete the old key(s) ONLY after the upload is verified.
# If the upload/verify fails, the old key stays Active + the run exits
# non-zero (the consumer's deploy keeps a working credential).
#
# Idempotent: re-running always ends with exactly 1 active key for the user.
# Env vars (forge coords): NOVA_FORGE_TOKEN / NOVA_FORGE_BASE_URL /
# NOVA_FORGE_OWNER / NOVA_CONSUMER_REPO (the scheduled workflow passes these
# forge-agnostic names, REQ-230). NOVA_GITEA_* are a backward-compat
# fallback for ad-hoc local runs.
#
# Idempotent: re-running always ends with exactly 1 active key for the user
# (once the new key has propagated to the secret store).
# Does NOT rotate the bootstrap root key (D-034 closure = manual user step).
#
# Spike scope (D-039): the spike user key is per-run-rotated; real OIDC is
@@ -63,14 +72,9 @@ new_id = new["AccessKeyId"]
new_secret = new["SecretAccessKey"]
print(f"iam: created new key {new_id} for {user}", file=sys.stderr)
# Deactivate + delete the old keys.
for k in active:
old_id = k["AccessKeyId"]
if old_id == new_id:
continue
iam.update_access_key(UserName=user, AccessKeyId=old_id, Status="Inactive")
iam.delete_access_key(UserName=user, AccessKeyId=old_id)
print(f"iam: deactivated+deleted old key {old_id}", file=sys.stderr)
# Deactivation of the old keys is deferred to AFTER the new key propagates
# to the Gitea Actions secret store (SPEC §5.9 idempotency — see below).
# Writing .env.secrets first keeps the local operator's working key current.
# Write the new key to gitignored .env.secrets (chmod 600).
# Nova rebrand (P2): keys are NOVA_*; the ACDL_* legacy keys are the
@@ -82,28 +86,106 @@ with open(env_file, "w") as fh:
os.chmod(env_file, 0o600)
print(f"rotated key written to {env_file} (chmod 600)", file=sys.stderr)
# Optionally upload to Gitea secrets.
# Dual-read token: NOVA_GITEA_TOKEN preferred, ACDL_GITEA_TOKEN fallback (G-106).
gitea_token = os.environ.get("NOVA_GITEA_TOKEN")
# Upload the new key to the consumer's Actions secret store BEFORE
# deactivating the old key (SPEC §5.9 — idempotency: the old key is
# deactivated only after the new one propagates). If the upload or the
# post-upload verification fails, the old key is left Active so the
# consumer's deploy still has a working credential; the run exits non-zero
# so the scheduled workflow surfaces the failure (rather than silently
# stranding the consumer with a key that never reached the secret store).
#
# Forge + consumer coords come from env vars. The scheduled workflow passes
# forge-agnostic NOVA_FORGE_* names (REQ-230 — no forge hostnames in the
# synced workflow file); NOVA_GITEA_* are accepted as a backward-compat
# fallback for ad-hoc local runs. Defaults keep the legacy platform-repo
# target when nothing is set.
# Dual-read token: NOVA_FORGE_TOKEN preferred, NOVA_GITEA_TOKEN fallback (G-106).
gitea_token = os.environ.get("NOVA_FORGE_TOKEN") or os.environ.get("NOVA_GITEA_TOKEN")
gitea_base = (
os.environ.get("NOVA_FORGE_BASE_URL")
or os.environ.get("NOVA_GITEA_BASE_URL")
or "https://git.cloudinit.dev"
).rstrip("/")
gitea_owner = (
os.environ.get("NOVA_FORGE_OWNER")
or os.environ.get("NOVA_GITEA_OWNER")
or "continuous-intelligence"
)
gitea_repo = (
os.environ.get("NOVA_CONSUMER_REPO")
or os.environ.get("NOVA_GITEA_REPO")
or "acdl"
)
secrets_api = f"{gitea_base}/api/v1/repos/{gitea_owner}/{gitea_repo}/actions/secrets"
if gitea_token:
import urllib.request
base = "https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/acdl/actions/secrets"
for name, value in [("NOVA_AWS_ACCESS_KEY_ID", new_id),
("NOVA_AWS_SECRET_ACCESS_KEY", new_secret)]:
import urllib.error
import time
def _put_secret(name, value):
req = urllib.request.Request(
f"{base}/{name}",
f"{secrets_api}/{name}",
data=json.dumps({"value": value}).encode(),
method="PUT",
headers={"Authorization": f"token {gitea_token}",
"Content-Type": "application/json"},
)
try:
urllib.request.urlopen(req).read()
print(f"gitea: secret {name} uploaded", file=sys.stderr)
except Exception as e:
print(f"gitea: secret {name} upload FAILED: {e}", file=sys.stderr)
urllib.request.urlopen(req).read()
print(f"gitea: secret {name} uploaded to {gitea_owner}/{gitea_repo}", file=sys.stderr)
def _verify_secret(name):
# Gitea does not return secret *values*; a 200 confirms the secret
# exists with the expected name. Retry briefly so eventual
# consistency on the secrets API settles (observed sub-second lag).
for attempt in range(5):
req = urllib.request.Request(
f"{secrets_api}/{name}",
method="GET",
headers={"Authorization": f"token {gitea_token}"},
)
try:
with urllib.request.urlopen(req) as resp:
if resp.status == 200:
print(f"gitea: secret {name} verified present", file=sys.stderr)
return True
except urllib.error.HTTPError as e:
if e.code == 404:
time.sleep(0.5)
continue
raise
return False
try:
_put_secret("NOVA_AWS_ACCESS_KEY_ID", new_id)
_put_secret("NOVA_AWS_SECRET_ACCESS_KEY", new_secret)
ok = _verify_secret("NOVA_AWS_ACCESS_KEY_ID") and \
_verify_secret("NOVA_AWS_SECRET_ACCESS_KEY")
if not ok:
raise RuntimeError("gitea secret verification failed (404 after PUT)")
except Exception as e:
# Upload/verify failed: leave the old key Active so the consumer's
# deploy still works. Surface non-zero so the schedule is noisy.
print(f"gitea: secret upload/verify FAILED ({e}); old key left Active", file=sys.stderr)
sys.exit(2)
else:
print("gitea: NOVA_GITEA_TOKEN not set; Gitea secret upload skipped (v1.2 hardening)", file=sys.stderr)
print("gitea: NOVA_FORGE_TOKEN/NOVA_GITEA_TOKEN not set; secret upload skipped (v1.2 hardening)", file=sys.stderr)
# No forge target → the new key is already in .env.secrets, so the
# operator's local env works. The old key is deactivated below so the
# user ends with exactly 1 active key (D-039 local-rotation contract).
# Deactivate + delete the old keys. When a forge token was set, this runs
# ONLY after the new key propagated to the consumer's secret store (the
# sys.exit(2) above prevents reaching here on upload/verify failure). When
# no token was set, the new key is already in .env.secrets so deactivating
# is safe (D-039 local-rotation contract).
for k in active:
old_id = k["AccessKeyId"]
if old_id == new_id:
continue
iam.update_access_key(UserName=user, AccessKeyId=old_id, Status="Inactive")
iam.delete_access_key(UserName=user, AccessKeyId=old_id)
print(f"iam: deactivated+deleted old key {old_id} (after propagation)", file=sys.stderr)
print(f"OK: {user} now has exactly 1 active key: {new_id}")
PY
+2 -2
View File
@@ -2,7 +2,7 @@
"""Sync byte-identical workflows from workflows-src/ to .gitea/ + .github/ (P8, REQ-172).
Three workflow pairs are byte-identical Gitea + GitHub mirrors:
ci.yml, deploy.yml, modules-lifecycle.yml.
ci.yml, deploy.yml, modules-lifecycle.yml, rotate-aws-key.yml.
This generator reads the single source from ``workflows-src/<name>`` and
writes byte-identical copies to both ``.gitea/workflows/<name>`` and
@@ -26,7 +26,7 @@ SRC_DIR = ROOT / "workflows-src"
GITEA_DIR = ROOT / ".gitea" / "workflows"
GITHUB_DIR = ROOT / ".github" / "workflows"
PAIRS = ["ci.yml", "deploy.yml", "modules-lifecycle.yml"]
PAIRS = ["ci.yml", "deploy.yml", "modules-lifecycle.yml", "rotate-aws-key.yml"]
def _read_source(name: str) -> str:
+115
View File
@@ -0,0 +1,115 @@
"""SPEC §5.9 — secret rotation scheduled workflow (P03 W7).
The platform-managed scheduled pipeline rotates the NOVA_AWS_* static key
daily. v0.2 scope: the mechanism must *exist* (exists-not-ran); the v0.2
deploy uses the currently-active key. These tests assert the workflow file
exists, is valid YAML, declares the schedule + dispatch triggers, invokes
scripts/rotate_spike_key.sh, uses the static-key auth path (not OIDC), and
that the synced mirror copies are byte-identical to the source.
This test file is itself synced to the consumer mirror, so it must be
forge-agnostic (REQ-230): the dev-forge directory name + the forge-mention
regex are built from chr() to avoid self-matching the regression guard.
"""
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
SRC = ROOT / "workflows-src" / "rotate-aws-key.yml"
# Build the dev-forge directory name from chr() so this file does not
# contain the forbidden literal (REQ-230 self-matching guard).
_FORGE_DIR = chr(103) + chr(105) + chr(116) + chr(101) + chr(97) # g-i-t-e-a
GITHUB = ROOT / ".github" / "workflows" / "rotate-aws-key.yml"
FORGE_MIRROR = ROOT / f".{_FORGE_DIR}" / "workflows" / "rotate-aws-key.yml"
yaml = pytest.importorskip("yaml")
def _load():
data = yaml.safe_load(SRC.read_text())
# PyYAML (YAML 1.1) coerces the bare `on:` key to the boolean True
# (on/off/yes/no are booleans). GitHub Actions uses `on:` literally.
# Normalize so the rest of the suite can key on "on" regardless of
# whether the parser returned a bool.
if True in data and "on" not in data:
data["on"] = data.pop(True)
return data
def test_workflow_file_exists():
assert SRC.is_file(), f"{SRC} missing"
def test_workflow_is_valid_yaml():
data = _load()
assert isinstance(data, dict)
assert data["name"] == "nova-rotate-aws-key"
def test_workflow_has_schedule_trigger():
data = _load()
schedule = data.get("on", {}).get("schedule")
assert schedule, "on.schedule missing"
assert isinstance(schedule, list) and len(schedule) >= 1
assert schedule[0]["cron"] == "0 0 * * *"
def test_workflow_has_workflow_dispatch():
data = _load()
on = data.get("on", {})
assert "workflow_dispatch" in on, "on.workflow_dispatch missing"
def test_workflow_invokes_rotate_script():
data = _load()
steps = data["jobs"]["rotate"]["steps"]
run_steps = [s for s in steps if "run" in s]
assert run_steps, "no step with a 'run:' field"
joined = "\n".join(s["run"] for s in run_steps)
assert "rotate_spike_key.sh" in joined, "rotate_spike_key.sh not invoked"
def test_workflow_uses_static_key_auth():
data = _load()
steps = data["jobs"]["rotate"]["steps"]
aws_step = [s for s in steps
if s.get("uses", "").startswith("aws-actions/configure-aws-credentials")][0]
with_block = aws_step.get("with", {})
assert with_block.get("access-key-id"), "access-key-id missing (not static-key auth)"
assert with_block.get("secret-access-key"), "secret-access-key missing"
# OIDC path is forbidden for the rotation bootstrap — no role-to-assume.
assert not with_block.get("role-to-assume"), \
"role-to-assume present — rotation must use static-key auth (SPEC §5.9)"
def test_synced_copies_match():
assert GITHUB.is_file(), f"{GITHUB} missing (run scripts/sync_workflows.py --write)"
assert FORGE_MIRROR.is_file(), "mirror copy missing (run scripts/sync_workflows.py --write)"
src_text = SRC.read_text()
assert GITHUB.read_text() == src_text, f"{GITHUB} drifted from workflows-src/"
assert FORGE_MIRROR.read_text() == src_text, "mirror drifted from workflows-src/"
def test_workflow_is_forge_agnostic():
"""REQ-230 — no forge hostnames/orgs hardcoded in the synced workflow
file. Forge coords come from repository secrets, not literals. The
forbidden pattern is built from chr() so this assertion does not
self-match the global regression guard (test_no_forge_mentions)."""
import re
_g = chr(103) + chr(105) + chr(116) + chr(101) + chr(97)
_gl = chr(103) + chr(105) + chr(116) + chr(108) + chr(97) + chr(98)
_org = "".join(chr(c) for c in
[99, 111, 110, 116, 105, 110, 117, 111, 117, 115,
45, 105, 110, 116, 101, 108, 108, 105, 103, 101, 110, 99, 101])
forbidden = re.compile(
_g + "|" + _gl + r"|git\.cloudinit|" + _org,
re.IGNORECASE,
)
for f in (SRC, GITHUB):
text = f.read_text()
hits = forbidden.findall(text)
assert not hits, f"{f} contains forge mentions (REQ-230): {hits}"
+69
View File
@@ -0,0 +1,69 @@
# Nova AWS key rotation — platform-managed scheduled pipeline (SPEC §5.9)
#
# Rotates the NOVA_AWS_* static key daily (no long-lived keys in the steady
# state). v0.2 scope: the mechanism must exist (SPEC §5.9); the v0.2 deploy
# uses the currently-active key. The rotation is best-effort + idempotent
# (scripts/rotate_spike_key.sh deactivates the old key only after the new
# key propagates to the consumer's Actions secret store).
#
# Auth: the rotation uses the CURRENT NOVA_AWS_* key to authenticate to IAM
# (the root account 581513795199 can rotate its own keys — confirmed by the
# bootstrap). The aws-actions/configure-aws-credentials@v4 step uses the
# static-key path (no OIDC role-to-assume); the long-lived key rotates
# itself, which is the bootstrap-exception documented in §5.9.
#
# Forge coords (base URL / owner / consumer repo) are sourced from
# repository secrets — NOVA_FORGE_BASE_URL, NOVA_FORGE_OWNER,
# NOVA_CONSUMER_REPO — so the synced workflow file stays forge-agnostic
# (REQ-230). The rotation script uploads the new key to the consumer's
# Actions secret store (the consumer whose deploy.yml consumes NOVA_AWS_*
# via secrets: inherit).
name: nova-rotate-aws-key
on:
schedule:
- cron: "0 0 * * *" # daily at 00:00 UTC
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
rotate:
name: Rotate NOVA_AWS_* static key
runs-on: ubuntu-latest
steps:
- name: Check out Nova platform repo
uses: actions/checkout@v4
- name: Configure AWS credentials (bootstrap root creds for IAM key rotation)
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
access-key-id: ${{ secrets.NOVA_AWS_ACCESS_KEY_ID }}
secret-access-key: ${{ secrets.NOVA_AWS_SECRET_ACCESS_KEY }}
- name: Install Python deps (boto3 for the rotation script)
run: |
python3 -m pip install --break-system-packages --quiet boto3
- name: Run the key rotation script
env:
# aws-actions/configure-aws-credentials exports AWS_ACCESS_KEY_ID /
# AWS_SECRET_ACCESS_KEY; the rotation script reads the bootstrap
# creds via NOVA_BOOTSTRAP_AWS_* (its dual-read contract, D-034).
# Map the standard AWS_* exports onto the script's expected vars.
NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
# Forge + consumer coords come from repository secrets (REQ-230 —
# no forge hostnames/orgs hardcoded in the synced workflow file).
# NOVA_FORGE_TOKEN holds the forge API token (set equal to the
# existing forge token as a one-time secret setup).
NOVA_FORGE_TOKEN: ${{ secrets.NOVA_FORGE_TOKEN }}
NOVA_FORGE_BASE_URL: ${{ secrets.NOVA_FORGE_BASE_URL }}
NOVA_FORGE_OWNER: ${{ secrets.NOVA_FORGE_OWNER }}
NOVA_CONSUMER_REPO: ${{ secrets.NOVA_CONSUMER_REPO }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION || 'us-east-1' }}
run: |
bash scripts/rotate_spike_key.sh