Deploying from the repository
The repository is the source of an Archestack app, the way source code is the source of a program. A deployment treats Archestack as the compiler: a pipeline builds a throwaway Archestack from the repository's files, runs the tests against it, and only when they pass does it plan the change against the hosted environment and apply it there. The hosted environment keeps serving its users throughout.
Status: the Archestack CLI runs every stage below, from a script step in any pipeline. Ready-made pipeline tasks for Azure DevOps and GitHub Actions that wrap it are in development; What runs today says what each stage runs.
The pipeline
merge to main
build a throwaway Archestack made from the repository's files
test every package's test cases, each in a sandbox
plan against tst: artifacts and SQL, with a plan hash
deploy tst: schema, then files, then the release
approve the plan against acc, then deploy acc
approve the plan against prd, then deploy prd A pull request runs the build, the tests and the plan against test, so a reviewer sees what would change before anything is merged. A merge to the main branch runs every stage, and acceptance and production each wait for a person to approve their plan.
1. Build: Archestack as the compiler
The pipeline starts an empty Archestack on the build agent, in Docker from the same API image the environments run, or from a runtime folder like the one the VS Code extension runs projects with, and loads the repository into it: every package's tables in one deployment, then each package's artifacts, dependencies before the packages that need them. This is where a mistake shows up first, the way a compiler error does: a reference to a business entity, table or script that does not exist, or a name another package already uses, stops the load. The result is an environment made from the files alone, with nothing left over from anyone's machine.
2. Test
Every package's test cases run against the build, each in a sandbox database of its own, with mail and notifications recorded rather than sent. Every script a test runs is compiled first, so a script that no longer compiles fails its test. One failed case stops the pipeline, and nothing is deployed.
3. Plan against the target
The same files are planned against the target environment: which artifacts are created, changed or removed there, and which table changes run as SQL, with every operation's risk and the typed scripts it would stop compiling. The plan carries a hash. The deployment applies exactly the plan that was approved: if the target changed in the meantime, it is refused and the plan is made again.
4. Deploy: schema, files, release
- Schema. The table changes run as a database deployment, listed in the environment's deployment history like any other. A change that loses data runs only when the pipeline explicitly allows data loss.
- Files. The artifacts are written in one transaction, so users see all of a package's new version or none of it. A deployment touches only what the package holds and creates, and never what each environment decides for itself.
- Release. Once the files are in, the pipeline publishes a release by itself. That is the moment the new pages and app layouts reach the published app, and nobody publishes by hand. Every deployment is a new version of the app with a release of its own.
An environment that has never published a release shows its drafts, so the new files would be visible the moment they land. Before its first deployment to such an environment, the pipeline therefore publishes a release of what the environment already holds, and from then on the new files wait as drafts until their own release, as in every later deployment.
Why the environment stays up
- Nothing restarts. A deployment is data in the environment's databases, not a new build of the server. The API keeps answering throughout, and a changed script is compiled the first time it runs.
- Files land at once. One transaction per package, so nobody works against half a package.
- Pages switch in one step. The published app reads the current release. New pages wait as drafts until the release is published, which moves every user to them at the same moment. Activating the previous release rolls back; that restores the pages and layouts, never data or tables.
- Table changes are planned and reviewed. Adding a table or a nullable column does not get in anyone's way, and anything that loses data needs an explicit yes.
Expand, then contract
Script modules, business events and business entities are live the moment their files land, not at the release. A change that renames or removes a column therefore goes out in two deployments:
- Expand. Add the new table or column, and deploy the scripts and pages that use it, with the old one still in place.
- Contract. In a later deployment, once nothing reads the old column any more, drop it. The plan marks the drop as data loss, and the typed script check says whether anything still uses it.
A change that rewrites a large table, such as a new type for a column, holds a lock on that table while it runs. Schedule those for a quiet moment.
Environments
The repository lists its environments in archestack.json. An environment marked
"mode": "pipeline" is deployed only by the pipeline, and the VS Code extension refuses
to push to it by hand:
"environments": {
"tst": { "url": "https://acme-tst.example.com", "apiUrl": "https://api.example.com", "workspace": "acme-tst.example.com", "mode": "pipeline" },
"acc": { "url": "https://acme-acc.example.com", "apiUrl": "https://api.example.com", "workspace": "acme-acc.example.com", "mode": "pipeline" },
"prd": { "url": "https://acme.example.com", "apiUrl": "https://api.example.com", "workspace": "acme.example.com", "mode": "pipeline" }
} What each environment decides for itself is not in the files and not in any deployment: users and roles, page permissions, public flags, Data API grants and keys, mail accounts and connections to other systems. Each environment's administrators set them, so a deployment can never make something public or give anyone access.
The pipeline file
The pipeline lives in the repository and deploys from it: every run checks out one commit, and
those files are what is built, tested and deployed. Nothing is exported from a workspace. Below is
the same pipeline for three CI systems, each taking a change to test, acceptance and production.
The archestack commands are those of the Archestack CLI, so any
other CI system that runs .NET and Docker on Linux runs the same steps.
- Build and test runs for every pull request and every merge to main. It builds a throwaway Archestack from the commit, runs every package's tests, and packs the repository into one artifact, so every environment gets exactly the same files.
- A plan and a deploy step per environment. The plan is made against the environment and shown in the run; the deploy then applies exactly that plan, after an approval for acceptance and production. Environments go one after another, so acceptance is planned only once test is deployed, and production only once acceptance is. A pull request stops after the plan against test.
- Deploy tokens. Each environment's token is made in that environment's builder,
under Operations, Deploy Tokens. It is shown once, acts as an admin, and works
only for deploying: it never reads the environment's data. The environment's address comes from
archestack.json.
Azure DevOps
The pipeline is azure-pipelines.yml at the repository's root:
# azure-pipelines.yml: build and test, then plan and deploy test, acceptance and production
trigger:
branches:
include: [main]
pr:
branches:
include: [main]
parameters:
- name: environments
type: object
default: [tst, acc, prd]
- name: allowDataLoss
displayName: Allow changes that lose data
type: boolean
default: false
pool:
vmImage: ubuntu-latest
stages:
- stage: build
displayName: Build and test
jobs:
- job: build
steps:
- checkout: self
displayName: The repository at this commit
- script: |
dotnet tool install --global Archestack.Cli
echo "##vso[task.prependpath]$HOME/.dotnet/tools"
displayName: Install the Archestack CLI
- script: archestack build
displayName: Build a throwaway Archestack from the repository
- script: archestack test --junit $(Agent.TempDirectory)/tests.xml
displayName: Run every package's tests
- task: PublishTestResults@2
condition: succeededOrFailed()
inputs:
testResultsFormat: JUnit
testResultsFiles: $(Agent.TempDirectory)/tests.xml
- script: archestack pack --out $(Build.ArtifactStagingDirectory)/app.zip
displayName: Pack the repository
- publish: $(Build.ArtifactStagingDirectory)
artifact: app
- ${{ each env in parameters.environments }}:
- stage: plan_${{ env }}
displayName: Plan ${{ env }}
${{ if ne(env, 'tst') }}:
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
variables:
- group: archestack-${{ env }}
jobs:
- job: plan
steps:
- download: current
artifact: app
- script: |
dotnet tool install --global Archestack.Cli
echo "##vso[task.prependpath]$HOME/.dotnet/tools"
displayName: Install the Archestack CLI
- script: >-
archestack plan --env ${{ env }}
--from $(Pipeline.Workspace)/app/app.zip
--allow-data-loss ${{ parameters.allowDataLoss }}
--out $(Build.ArtifactStagingDirectory)/plan.json
--summary $(Build.ArtifactStagingDirectory)/plan.md
displayName: Plan against ${{ env }}
env:
ARCHESTACK_TOKEN: $(ARCHESTACK_TOKEN)
- script: echo "##vso[task.uploadsummary]$(Build.ArtifactStagingDirectory)/plan.md"
displayName: Show the plan in the run
- publish: $(Build.ArtifactStagingDirectory)
artifact: plan-${{ env }}
- stage: deploy_${{ env }}
displayName: Deploy ${{ env }}
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
variables:
- group: archestack-${{ env }}
jobs:
- deployment: deploy
environment: archestack-${{ env }}
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: app
- download: current
artifact: plan-${{ env }}
- script: |
dotnet tool install --global Archestack.Cli
echo "##vso[task.prependpath]$HOME/.dotnet/tools"
displayName: Install the Archestack CLI
- script: >-
archestack deploy --env ${{ env }}
--from $(Pipeline.Workspace)/app/app.zip
--plan $(Pipeline.Workspace)/plan-${{ env }}/plan.json
displayName: Schema, files and release
env:
ARCHESTACK_TOKEN: $(ARCHESTACK_TOKEN) - Approvals. Create the environments
archestack-tst,archestack-accandarchestack-prdunder Pipelines, Environments, and add an Approvals check to acceptance and production. Azure DevOps then waits for an approver before those deploy stages start, with the plan in the run's summary. The test results are published to the run. - Credentials. Each environment's deploy token is the secret
ARCHESTACK_TOKENin a variable group of the same name as the environment, such asarchestack-prd. - Data loss. Allow changes that lose data is a checkbox when a run is started by hand, off by default. Without it, a plan that drops a table or a column stops the pipeline.
- Pull requests on Azure Repos. Azure Repos ignores the
pr:section; add the pipeline as a build validation branch policy on main instead. - A pipeline made in the web editor is saved as a new file named after the
pipeline. Point the pipeline at
azure-pipelines.ymlunder its settings, or the file you edit is not the one that runs.
GitHub Actions
The workflow is .github/workflows/archestack.yml:
# .github/workflows/archestack.yml: build and test, then plan and deploy test, acceptance and production
name: Archestack
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
allow-data-loss:
description: Allow changes that lose data
type: boolean
default: false
permissions:
contents: read
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Install the Archestack CLI
run: dotnet tool install --global Archestack.Cli
- name: Build a throwaway Archestack from the repository
run: archestack build
- name: Run every package's tests
run: archestack test --junit "$RUNNER_TEMP/tests.xml"
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: tests
path: ${{ runner.temp }}/tests.xml
- name: Pack the repository
run: archestack pack --out "$RUNNER_TEMP/app/app.zip"
- uses: actions/upload-artifact@v4
with:
name: app
path: ${{ runner.temp }}/app
# A pull request stops after the plan against test.
tst:
needs: build
uses: ./.github/workflows/archestack-deploy.yml
with:
environment: tst
deploy: ${{ github.event_name != 'pull_request' }}
allow-data-loss: ${{ inputs.allow-data-loss == true }}
secrets:
token: ${{ secrets.ARCHESTACK_TOKEN_TST }}
acc:
if: github.event_name != 'pull_request'
needs: tst
uses: ./.github/workflows/archestack-deploy.yml
with:
environment: acc
allow-data-loss: ${{ inputs.allow-data-loss == true }}
secrets:
token: ${{ secrets.ARCHESTACK_TOKEN_ACC }}
prd:
if: github.event_name != 'pull_request'
needs: acc
uses: ./.github/workflows/archestack-deploy.yml
with:
environment: prd
allow-data-loss: ${{ inputs.allow-data-loss == true }}
secrets:
token: ${{ secrets.ARCHESTACK_TOKEN_PRD }} Each environment is planned and deployed by the same reusable workflow beside it:
# .github/workflows/archestack-deploy.yml: plans the packed files against one environment, then deploys that plan
name: Archestack environment
on:
workflow_call:
inputs:
environment:
type: string
required: true
deploy:
type: boolean
default: true
allow-data-loss:
type: boolean
default: false
secrets:
token:
required: true
jobs:
plan:
name: Plan ${{ inputs.environment }}
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: app
path: app
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- run: dotnet tool install --global Archestack.Cli
- name: Plan against ${{ inputs.environment }}
run: >-
archestack plan --env ${{ inputs.environment }}
--from app/app.zip
--allow-data-loss ${{ inputs.allow-data-loss }}
--out plan/plan.json
--summary plan/plan.md
env:
ARCHESTACK_TOKEN: ${{ secrets.token }}
- name: Show the plan in the run
if: ${{ !cancelled() }}
run: cat plan/plan.md >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v4
with:
name: plan-${{ inputs.environment }}
path: plan
deploy:
name: Deploy ${{ inputs.environment }}
if: inputs.deploy
needs: plan
runs-on: ubuntu-latest
environment: archestack-${{ inputs.environment }}
concurrency: archestack-${{ inputs.environment }}
steps:
- uses: actions/download-artifact@v4
with:
name: app
path: app
- uses: actions/download-artifact@v4
with:
name: plan-${{ inputs.environment }}
path: plan
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- run: dotnet tool install --global Archestack.Cli
- name: Schema, files and release
run: archestack deploy --env ${{ inputs.environment }} --from app/app.zip --plan plan/plan.json
env:
ARCHESTACK_TOKEN: ${{ secrets.token }} - Approvals. Create the environments
archestack-tst,archestack-accandarchestack-prdunder Settings, Environments, and add required reviewers to acceptance and production. The deploy job waits for a reviewer, with the plan already in the run's summary. On a private repository, environments need GitHub Pro, Team or Enterprise. - Credentials. The deploy tokens are the repository secrets
ARCHESTACK_TOKEN_TST,ARCHESTACK_TOKEN_ACCandARCHESTACK_TOKEN_PRD, so the plan can run before the approval. A workflow on any branch of the repository can read a repository secret; to keep production's token to approved runs, make it an environment secret ofarchestack-prdinstead, and its plan then runs after the approval. - Data loss. Allow changes that lose data is a checkbox under Run workflow, off by default.
- Pull requests from forks get no secrets, so their plan against test fails; pull requests from branches of the repository itself plan as usual.
-
concurrencykeeps two runs from deploying to one environment at the same time. The test results are kept as the run'stestsartifact.
GitLab CI
The pipeline is .gitlab-ci.yml at the repository's root:
# .gitlab-ci.yml: build and test, then plan and deploy test, acceptance and production
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
variables:
ALLOW_DATA_LOSS:
value: "false"
description: Allow changes that lose data (true or false)
stages: [build, tst, acc, prd]
default:
image: mcr.microsoft.com/dotnet/sdk:8.0
before_script:
- dotnet tool install --global Archestack.Cli
- export PATH="$PATH:$HOME/.dotnet/tools"
build:
stage: build
# The throwaway Archestack runs in Docker, here a Docker service beside the job.
services:
- docker:27-dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
script:
- curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz | tar -xz -C /usr/local/bin --strip-components=1 docker/docker
- archestack build
- archestack test --junit tests.xml
- archestack pack --out app.zip
artifacts:
when: always
paths: [app.zip]
reports:
junit: tests.xml
.plan:
script:
- archestack plan --env "$TARGET" --from app.zip --allow-data-loss "$ALLOW_DATA_LOSS" --out plan.json --summary plan.md
artifacts:
when: always
expose_as: plan
paths: [plan.md, plan.json]
.deploy:
script:
- archestack deploy --env "$TARGET" --from app.zip --plan plan.json
# A merge request stops after the plan against test.
plan-tst:
extends: .plan
stage: tst
needs: [build]
variables: { TARGET: tst }
deploy-tst:
extends: .deploy
stage: tst
needs: [build, plan-tst]
environment: archestack-tst
resource_group: archestack-tst
variables: { TARGET: tst }
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
plan-acc:
extends: .plan
stage: acc
needs: [build, deploy-tst]
variables: { TARGET: acc }
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
deploy-acc:
extends: .deploy
stage: acc
needs: [build, plan-acc]
environment: archestack-acc
resource_group: archestack-acc
variables: { TARGET: acc }
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
allow_failure: false
plan-prd:
extends: .plan
stage: prd
needs: [build, deploy-acc]
variables: { TARGET: prd }
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
deploy-prd:
extends: .deploy
stage: prd
needs: [build, plan-prd]
environment: archestack-prd
resource_group: archestack-prd
variables: { TARGET: prd }
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
allow_failure: false - Docker. The build runs the throwaway Archestack on a
docker:dindservice beside the job, which needs a runner that allows privileged containers, as GitLab.com's own runners do.DOCKER_HOSTtells the CLI where Docker is, and it reaches the Archestack there. - Approvals. Deploying acceptance and production are manual jobs that block the pipeline, each started once its plan has been read. Every plan is kept with its job, and the plan against test is also linked from the merge request. On GitLab Premium, also protect those environments and require approvals.
- Credentials. The deploy tokens are the masked CI/CD variables
ARCHESTACK_TOKEN_TST,ARCHESTACK_TOKEN_ACCandARCHESTACK_TOKEN_PRD. Protect acceptance's and production's, so only the main branch reads them. A protected variable does not reach a merge request pipeline, so leave test's unprotected for merge requests to plan against test. - Data loss. Run the pipeline by hand with
ALLOW_DATA_LOSSset totrue. Test results show in the merge request. -
resource_groupkeeps two pipelines from deploying to one environment at the same time.
The Archestack CLI
The CLI is the .NET tool Archestack.Cli, installed with
dotnet tool install --global Archestack.Cli on any agent with .NET 8 or later. Its
commands run from the repository's root, or take --from with a folder or a packed ZIP:
-
archestack buildstarts the throwaway Archestack and loads the repository into it. It runs in Docker from the image theprojectsection ofarchestack.jsonnames, else the latest published one;--imagepicks another, and--runtimeruns a runtime folder without Docker. It is ready once the NuGet packages the repository names are restored, and keeps running for the next step untilarchestack stop.--nuget-cache <folder>keeps those downloads for the next build, which otherwise downloads them again; two builds running at once each need a folder of their own. -
archestack testruns every package's test cases against it, four at a time, and writes JUnit with--junit. -
archestack pack --out app.zippacksarchestack.json,packages/andworkspace/. The same files always give the same bytes, and the plan records their hash, so a deployment refuses files other than the ones planned. -
archestack planandarchestack deploywork only on an environment marked"mode": "pipeline", with its deploy token inARCHESTACK_TOKEN_<ENV>(such asARCHESTACK_TOKEN_PRD) orARCHESTACK_TOKEN. An artifact deleted from the repository is taken out of its package and left in the environment; plan with--delete-removed trueto delete it there too. A package in the environment that the repository does not hold is left alone and named in the plan. When the repository names NuGet packages, both first ask the environment whether they restore there, anddeploywaits up to five minutes after the last push until the environment has restored them, before it publishes the release. If that wait fails, the files are deployed but no release is published.
The exit code says what stopped a step: 3 the token was refused, 4 the environment changed since
the plan (plan again), 5 the files cannot be applied as they stand, 6 the plan loses data and that
was not allowed, 7 a test failed, 9 the server or the network failed, 10 the environment runs
another major version of Archestack than the CLI, or one without NuGet support. NuGet packages
that do not restore with the files are a 5, and so is an environment that has NuGet packages or
typed data access turned off. For plan and deploy, a NuGet failure the
files cannot fix, such as a server short of disk space, a feed it cannot reach, or a restore after
the last push that failed or did not finish in five minutes, is a 9, so the same step can pass
when it runs again.
What runs today
- Build, test, plan and deploy run on the CLI, from script steps as in the pipelines above. On a developer's machine the VS Code extension's project is the same build: Reset Project Database makes it from nothing, and Run Tests runs every package's tests against it.
- Pipeline tasks for Azure DevOps and GitHub Actions that wrap the CLI: in development.
Gotchas
- A rollback does not undo tables or data. Every deployment is a release, so any version the pipeline deployed can be activated again, but that restores pages and layouts only. Taking tables back is a deployment of its own, planned and reviewed like any other.
- Script modules and business events do not wait for the release. They are live the moment their files land, which is why a change that removes something goes out in two deployments.
- Every environment downloads its own NuGet packages. The
NuGet packages a package's
nuget.jsondeclares are downloaded by each environment that receives it, the build included, so each needs to reach nuget.org or the feed its servers are configured with. The build takesScripting__NuGet__Sourcesand theScripting__NuGet__Feeds__*settings from the environment the CLI runs in. A push whose NuGet packages cannot be restored is refused and changes nothing: the CLI'splanchecks that before anything is deployed, andbuildanddeploywait until they are restored.
Where this goes next
Working as a team covers how developers share the repository that the pipeline deploys. Workspaces and hosting explains the environments it deploys to, and Database Deployments the schema step.