CI/CD Guide — middag-io
Complete reference for GitHub Actions, 1Password secrets, AWS deploy, and legacy Bitbucket migration.
Table of Contents
- 1. Overview
- 2. 1Password — Day-to-Day Usage
- 3. GitHub Actions — Reusable Workflows
- 4. Docker Registry (GHCR / ECR)
- 5. Deploy to Production
- 6. Adding a New Project
- 7. Adding a New Secret
- 8. Legacy Migration from Bitbucket
- 9. Troubleshooting
1. Overview
Architecture
Developer ──push──> GitHub (middag-io/{repo})
│
▼
GitHub Actions
(reusable workflows from middag-io/.github-private)
│
┌────────────┼───────────┐
▼ ▼ ▼
1Password GHCR Cloudflare
(secrets) (images) (Pages)
│ │
▼ ▼
EC2 + 1Password Connect
(production / staging)Key Decisions
| Decision | Reference |
|---|---|
| Repo naming | ADR-001 |
Branch model (main + develop) | ADR-002 |
| Release strategy (release-please) | ADR-003 |
| Migration strategy | ADR-004 |
| 1Password integration | ADR-005 |
2. 1Password — Day-to-Day Usage
Two Modes
| Context | Mechanism | When to Use |
|---|---|---|
| CI (GitHub Actions) | Service Account Token | Workflows, build, test, deploy scripts |
| Servers (EC2/Docker) | 1Password Connect | Production/staging containers, op inject |
For Developers: How secrets flow
1. Secret lives in 1Password vault (e.g., CI-MYPROJECT)
2. Code references it as op://CI-MYPROJECT/item-name/field-name
3. In CI: 1password/load-secrets-action resolves it
4. On server: op inject resolves it via Connect
5. Developer never sees or copies the secret valueAdding a secret to a project
Create the item in the appropriate 1Password vault:
Vault Use for CI-SHAREDShared across all projects (GHCR, SES, Cloudflare global) CI-AWSAWS credentials (ECR, RDS, Lambda) CI-{PROJECT}Project-specific (deploy keys, API keys, certificates) PRIVATEPrivate keys, certificates Reference it in your
.env.*.tplor workflow:# In .env.production.tpl MY_SECRET="op://CI-MYPROJECT/my-item/my-field"yaml# In GitHub Actions workflow - uses: 1password/load-secrets-action@v2 env: OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SA_MYPROJECT }} MY_SECRET: op://CI-MYPROJECT/my-item/my-fieldVerify the vault is accessible by the correct Service Account or Connect server.
Vault access changes
| Context | What to do |
|---|---|
| Connect (server) | 1Password web → Integrations → Connect server → add vault. No redeploy. |
| Service Account (CI) | If vault already in SA scope: nothing. If new vault needed: create new SA token, update GitHub secret. |
Service Account naming
sa-github-ci-shared → org-level (CI-SHARED + CI-AWS)
sa-github-ci-myproject → my-project project
sa-github-ci-{project} → new projectGitHub secret placement
| Secret | Level | Where |
|---|---|---|
OP_SERVICE_ACCOUNT_TOKEN | Org | GitHub org settings → Secrets → Actions |
OP_SA_{PROJECT} | Repo | GitHub repo settings → Secrets → Actions |
OP_CONNECT_TOKEN | Server | EC2 environment (not in GitHub) |
3. GitHub Actions — Reusable Workflows
All CI/CD workflows live in middag-io/.github-private and are called by individual repos.
Available Workflows
| Workflow | Purpose | Used by |
|---|---|---|
wp-plugin-ci.yml | PHP matrix + optional UI checks (configurable via inputs) | wp-plugin-* repos |
wp-theme-ci.yml | PHP matrix for themes | wp-theme-* repos |
wp-plugin-post-release.yml | Build dist ZIP, upload to GH Release, trigger privatesatis | wp-plugin-* repos |
release-please.yml | Version bump, changelog, GitHub Release | All repos |
docs-deploy.yml | VitePress build + Cloudflare Pages deploy (hash-gated) | Repos with docs |
docker-build-push.yml | Multi-arch build, GHCR push, optional ECR copy | docker-* repos (planned) |
docker-deploy.yml | SSH deploy to EC2 | docker-* repos (planned) |
moodle-plugin-ci.yml | moodle-plugin-ci checks | moodle-* repos (planned) |
composer-package-ci.yml | PHPUnit, PHPStan, PHPCS | PHP library repos (planned) |
How to use in your repo
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
ci:
uses: middag-io/.github-private/.github/workflows/wp-plugin-ci.yml@workflows-v1
with:
has-ui: true
has-tests: true
has-rector: true
secrets: inherit# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
jobs:
release-please:
uses: middag-io/.github-private/.github/workflows/release-please.yml@workflows-v1
permissions:
contents: write
pull-requests: write
secrets: inherit
post-release:
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
uses: middag-io/.github-private/.github/workflows/wp-plugin-post-release.yml@workflows-v1
with:
tag-name: ${{ needs.release-please.outputs.tag_name }}
has-ui: true
has-strauss: true
zip-name: my-plugin
permissions:
contents: write
secrets: inheritWorkflow inputs and variables
Per-repo configuration is now passed as workflow inputs (not repo variables). Org-level secrets and variables are shared via secrets: inherit.
Workflow inputs (per-repo, passed in with:)
| Input | Workflow | Purpose | Example |
|---|---|---|---|
zip-name | wp-plugin-post-release | Plugin folder name for ZIP archive | my-project |
has-ui | wp-plugin-ci / post-release | Plugin has ui/ directory | true |
has-strauss | wp-plugin-post-release | Strauss vendor prefixing | true |
has-tests | wp-plugin-ci | Run composer test | true |
has-phpstan | wp-plugin-ci / theme | Run PHPStan static analysis | true |
has-cs-fixer | wp-plugin-ci / theme | Run PHP CS Fixer | true |
has-rector | wp-plugin-ci / theme | Run Rector | false |
needs-satis-auth | wp-plugin-post-release | Composer needs privatesatis auth | true |
docs-path | docs-deploy | VitePress docs directory | docs/ |
project-name | docs-deploy | Cloudflare Pages project name | my-project-docs |
Org secrets (shared via secrets: inherit)
| Secret | Visibility | Purpose |
|---|---|---|
OP_SERVICE_ACCOUNT_TOKEN | ALL | 1Password Service Account (org-wide, CI-SHARED + CI-AWS) |
PRIVATESATIS_PASSWORD | ALL | HTTP Basic password for privatesatis.middag.com.br |
PRIVATESATIS_DISPATCH_TOKEN | ALL | GitHub PAT with repo scope — triggers repository_dispatch on my-satis-repo |
CLOUDFLARE_API_TOKEN | ALL | Cloudflare API token (Pages deploy) |
DOCKERHUB_TOKEN | ALL | Docker Hub push token |
NPM_TOKEN | PRIVATE | npm registry publish token |
SMTP_PASSWORD | PRIVATE | SES SMTP password |
PRIVATESATIS_BOT_APP_ID | ALL | GitHub App ID for privatesatis-bot (cross-repo Composer auth) |
PRIVATESATIS_BOT_PRIVATE_KEY | ALL | GitHub App private key (PEM) for privatesatis-bot |
Org variables (shared via secrets: inherit)
| Variable | Visibility | Value | Purpose |
|---|---|---|---|
PRIVATESATIS_USERNAME | ALL | github-middag-io | HTTP Basic username for privatesatis |
CLOUDFLARE_ACCOUNT_ID | ALL | 32ff8929... | Cloudflare account |
DOCKERHUB_USERNAME | ALL | middagtec | Docker Hub push username |
SENTRY_ORG | ALL | middag | Sentry organization slug |
SMTP_HOST | PRIVATE | email-smtp.us-east-1... | SES SMTP host |
SMTP_PORT | PRIVATE | 587 | SES SMTP port |
SMTP_USER | PRIVATE | AKIA... | SES SMTP IAM user |
Feature flags — repo variables ("truques")
Repo-level variables act as feature flags to enable optional workflow steps without changing workflow code. Set them in GitHub repo Settings → Variables → Actions.
| Variable | Scope | Value | Purpose |
|---|---|---|---|
PUSH_TO_ECR | Repo var | true | Enable crane copy from GHCR to ECR after build (see ADR-007) |
ECR_REPO | Repo var | my-project | ECR repository name (required when PUSH_TO_ECR=true) |
Pattern: any workflow step can be gated with if: vars.MY_FLAG == 'true'. This avoids per-repo workflow forks — one reusable workflow handles all repos, repo variables control behavior.
4. Docker Registry (GHCR / ECR)
Default: GHCR (GitHub Container Registry)
All Docker images push to GHCR by default:
ghcr.io/middag-io/{repo-name}/{service}:{tag}Example:
ghcr.io/middag-io/docker-wp-my-project/wordpress:a1b2c3d4e5f6
ghcr.io/middag-io/docker-wp-my-project/nginx:latestOptional: ECR (AWS Elastic Container Registry)
Enabled per-repo via repository variables:
PUSH_TO_ECR=true
ECR_REPO=my-projectWhen enabled, docker-build-push.yml uses crane copy to replicate from GHCR to ECR after build (no rebuild):
GHCR (build target) ──crane copy──> ECR (deploy source)ECR credentials from 1Password vault CI-AWS.
When to use ECR
- Production servers on AWS that benefit from same-region pull
- Currently: only
docker-wp-my-projectneeds ECR - Default for new projects: GHCR only unless specific need
5. Deploy to Production
Docker projects (EC2)
GitHub Actions EC2
│ │
├── Build & push to GHCR │
├── (optional) crane copy to ECR │
├── SCP: docker-compose.yml, │
│ .env.production.tpl, │
│ Makefile │
├── SSH: docker compose pull │
│ op inject .env.tpl → .env │ ← 1Password Connect (local)
│ docker compose up -d │
└── Cleanup │WordPress plugin release
1. Merge PR from develop → main
2. release-please detects conventional commits on main
3. Creates/updates Release PR (version bump + CHANGELOG.md)
4. Merge Release PR → triggers:
a. GitHub Release created (with tag)
b. Post-release workflow: build UI → Strauss → ZIP → upload to Release
c. Trigger privatesatis rebuild (repository_dispatch)
d. Deploy docs to Cloudflare Pages (if applicable)WordPress theme release
Same as plugin but version in style.css + functions.php.
6. Adding a New Project
Checklist
Create repo following ADR-001:
bashgh repo create middag-io/{name} --private --description "{desc}"Set default branch to
main, createdevelopbranchAdd topics (platform, type, client)
Add CI workflow calling reusable workflow:
yamljobs: ci: uses: middag-io/.github-private/.github/workflows/wp-plugin-ci.yml@workflows-v1 with: has-tests: true secrets: inheritAdd release-please config:
- Create
release-please-config.jsonwithsimplerelease type andextra-filesfor version header - Create
.release-please-manifest.jsonwith current version - Add
// x-release-please-versionannotations to version lines in main plugin/theme file - Add release workflow calling
release-please.yml+wp-plugin-post-release.yml
- Create
Configure secrets (if project needs its own SA):
- Create Service Account in 1Password:
sa-github-ci-{project} - Grant access to appropriate vaults
- Add token as repo secret:
OP_SA_{PROJECT}
- Create Service Account in 1Password:
Set repo variables (
vars.*) for workflow configConfigure branch protection (or verify org ruleset applies)
Test CI — push to develop, open PR, merge to main
7. Adding a New Secret
Decision tree
Is it shared across projects?
├── Yes → Add to CI-SHARED or CI-AWS vault
│ (already accessible via org SA)
│
└── No → Is there a project vault (CI-{PROJECT})?
├── Yes → Add to CI-{PROJECT}
│ (already accessible via project SA)
│
└── No → Create CI-{PROJECT} vault
Create sa-github-ci-{project} SA
Add OP_SA_{PROJECT} to repo secretsAfter adding the secret
- Reference it in code:
op://CI-{VAULT}/item-name/field-name - If used in CI workflow: add to
1password/load-secrets-actionenv block - If used on server: add to
.env.production.tpl(Connect resolves automatically) - Test in CI and/or staging before production
8. Legacy Migration from Bitbucket
Wave 1 (WordPress) is COMPLETE. The tables below remain useful reference for future migration waves.
Variable mapping
| Bitbucket | GitHub Actions |
|---|---|
$BITBUCKET_COMMIT | ${{ github.sha }} |
$BITBUCKET_REPO_SLUG | ${{ github.event.repository.name }} |
$BITBUCKET_REPO_FULL_NAME | ${{ github.repository }} |
$BITBUCKET_BRANCH | ${{ github.ref_name }} |
$BITBUCKET_TAG | ${{ github.ref_name }} (on tag trigger) |
$BITBUCKET_SSH_KEY_FILE | Deploy key or ssh-agent action |
$BITBUCKET_BOT_USERNAME | github-actions[bot] |
$BITBUCKET_BOT_PASSWORD | ${{ secrets.GITHUB_TOKEN }} |
Pipe → Action mapping
| Bitbucket Pipe | GitHub Equivalent |
|---|---|
atlassian/bitbucket-upload-file | gh release upload or actions/upload-artifact |
atlassian/trigger-pipeline | repository_dispatch or workflow_dispatch |
Pipeline → Workflow migration steps
- Read
bitbucket-pipelines.yml - Identify steps, triggers, conditions, services
- Map to GitHub Actions workflow using reusable workflows
- Replace BB variables with GH equivalents (table above)
- Replace BB pipes with GH actions (table above)
- Move BB pipeline variables to 1Password or GH repo variables
- Test on develop branch before merging to main
What is NOT migrated
- Bitbucket Issues and Pull Requests (Jira used for tracking)
- Bitbucket Downloads (move backups to S3 or GitHub Releases)
- Pipeline build history (irrelevant)
Migration Status
| Wave | Status | Notes |
|---|---|---|
| 0 — Infra | COMPLETE | .github, CI workflows, org config |
| 1 — WP | COMPLETE | 6 repos migrated, BB pipelines removed, reusable workflows + release-please |
| 2 — Libs | Pending | Rename + topics/properties polish |
| 3 — Apps | Pending | |
| 4 — Moodle core | Pending | Complex CI |
| 5 — Moodle sites | Pending | Per-site migration |
9. Troubleshooting
1Password in CI
| Problem | Cause | Fix |
|---|---|---|
could not find item | Wrong vault or item name in op:// | Verify vault name + item title in 1Password |
unauthorized | SA token doesn't have vault access | Check SA permissions in 1Password web |
connect: connection refused | Connect not running (infra only) | docker compose up -d op-connect-api op-connect-sync |
GitHub Actions
| Problem | Cause | Fix |
|---|---|---|
secrets: inherit not working | Repo not in org, or wrong workflow path | Verify repo under middag-io org |
| Reusable workflow not found | Wrong ref or path | middag-io/.github-private/.github/workflows/{name}.yml@workflows-v1 |
| Permission denied pushing tag | Branch protection blocks bot | Add bot to bypass list in ruleset |
Docker
| Problem | Cause | Fix |
|---|---|---|
| GHCR push denied | Token lacks write:packages | Check GHCR_TOKEN scope in 1Password |
| ECR login failed | AWS credentials expired | Verify CI-AWS vault credentials |
crane copy fails | Auth to target registry | Ensure both registries logged in before copy |