Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

A second-generation package is defined by your repository, not by an org. You describe the package in sfdx-project.json, build a version from source with the CLI, install it somewhere to test, then promote it. Four commands and one file. The setup takes an afternoon, and the thing you get back is a build you can reproduce from a commit.

What you need before you start

  • A Dev Hub org. Package definitions and versions live here, not in the packaging org model of the old world.
  • A namespace linked to that Dev Hub, if the package is managed. Unlocked packages can go without one.
  • A scratch org definition file, because every version build spins up a temporary org to compile and validate against.
  • The sf CLI authenticated to the Dev Hub.

The project file

This is the whole configuration. Everything else is a command that reads it.

{
  "packageDirectories": [
    {
      "path": "force-app",
      "package": "Invoicing",
      "versionName": "Autumn release",
      "versionNumber": "1.3.0.NEXT",
      "default": true,
      "ancestorVersion": "HIGHEST",
      "definitionFile": "config/project-scratch-def.json",
      "dependencies": [
        { "package": "Core@2.1.0-4" }
      ]
    }
  ],
  "namespace": "acme",
  "packageAliases": {
    "Invoicing": "0Ho5f000000XXXXCAQ",
    "Core@2.1.0-4": "04t5f000000YYYYAAI"
  }
}

Three fields deserve attention. versionNumber ends in NEXT, which tells the CLI to increment the build number itself, so you never edit it by hand. ancestorVersion declares what this version upgrades from, and a managed package without it produces something nobody can upgrade to. packageAliases maps readable names to the long IDs, and the CLI maintains it for you.

The four commands

Create the package once, ever:

sf package create --name Invoicing --package-type Managed \
  --path force-app --target-dev-hub DevHub

Build a version, as often as you like:

sf package version create --package Invoicing --code-coverage \
  --installation-key-bypass --wait 60 --target-dev-hub DevHub

This is the slow step. Salesforce creates a build org, deploys the source, compiles it, runs the Apex tests if you asked for coverage, and produces a subscriber package version ID beginning 04t. Failures here are almost always genuine compile or dependency errors, and the output names the metadata.

Install it somewhere that is not your development org:

sf package install --package "Invoicing@1.3.0-1" \
  --target-org uat --wait 20 --publish-wait 10

Then, once it has passed whatever your definition of tested is:

sf package version promote --package "Invoicing@1.3.0-1" --target-dev-hub DevHub

Promotion marks the version released. For a managed package that is one-way. The version becomes installable in production orgs, becomes an ancestor for future versions, and cannot be unpromoted or altered.

Dependencies

Declaring a dependency makes the build fail early when it is missing, which is the whole benefit. List packages in install order, and pin a specific version for anything you ship, because a reproducible build matters more than being current.

Two packages that depend on each other are not possible. If you find yourself wanting that, the split is in the wrong place, and the fix is a shared package underneath both rather than clever ordering.

Code coverage and promotion

A managed package version needs 75% Apex coverage before promotion, calculated at build time from the tests inside the package. That means --code-coverage on the build. It makes the build slower, and skipping it means building again when promotion refuses.

Coverage is also why --skip-validation is a trap. It produces a version much faster, and that version cannot be promoted. It is for throwaway builds in a feature branch, not for anything heading towards a release.

A working rhythm

  1. Every merge to the main branch builds a version with validation and coverage. It takes a while and nobody waits for it.
  2. That version installs automatically into a long-lived test org that already holds data from previous releases.
  3. A release is a promote of a version that already exists and has been running in that org for days.

The point of the third step is that release day involves no build. The artefact is already there, already tested, already installed somewhere real. That is the difference between packaging and deployment, and it is the reason to take on the overhead.

Frequently asked

Managed or unlocked? Managed if you distribute to orgs you do not control, or need a namespace and intellectual property protection. Unlocked packages for your own org’s modular metadata.

Can I delete a package version? A beta version can be deleted. A promoted one cannot, which is the reason promotion is a separate command rather than a flag.

How do I see what exists? sf package version list --target-dev-hub DevHub shows every version with its build date, promotion state and coverage.

Does the build org need my data? No, and it should have none. If a version only builds with specific records present, something reads data at deploy time, and that will bite a subscriber too.